Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 85 additions & 14 deletions src/lung_utils/hamilton_ventilator/waveform_plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pandas as pd
import plotly.graph_objs as go
from dash import Input, Output, dcc, html
from plotly.subplots import make_subplots


# ==== Load waveform file ====
Expand All @@ -21,7 +22,16 @@ def load_waveform_txt(filepath):
df = df.dropna(how="all")
df["Date_Time"] = pd.to_numeric(df["Date_Time"], errors="coerce")
df = df.dropna(subset=["Date_Time"])
df["Time (s)"] = df["Date_Time"] - df["Date_Time"].iloc[0]

# Calculate relative time in seconds from start
start_time_val = df["Date_Time"].iloc[0]
df["Time (s)"] = (df["Date_Time"] - start_time_val) * 24 * 3600

# Convert OLE Automation Date (Excel date) to absolute datetime
df["Absolute_Time"] = pd.to_datetime(
df["Date_Time"], unit="D", origin="1899-12-30"
)

return df


Expand All @@ -31,24 +41,43 @@ def create_dash_app(df, file_path):
app.title = "Hamilton Waveform Viewer"

# Select waveform columns
exclude_cols = ["Date_Time", "Time (s)", "Breath Number", "Status"]
exclude_cols = [
"Date_Time",
"Time (s)",
"Absolute_Time",
"Breath Number",
"Status",
]
waveform_columns = [col for col in df.columns if col not in exclude_cols]

# Get absolute start time
start_time_str = ""
if not df.empty and "Absolute_Time" in df.columns:
start_time_str = (
df["Absolute_Time"].iloc[0].strftime("%Y-%m-%d %H:%M:%S")
)

app.layout = html.Div(
[
html.H2("Hamilton Ventilator Waveform Viewer"),
html.Div(
f"Loaded file: {file_path}",
id="file-info",
style={"marginBottom": "10px"},
style={"marginBottom": "5px"},
),
html.Label("Select waveform:"),
html.Div(
f"Recording Start Time: {start_time_str}",
id="start-time-info",
style={"marginBottom": "10px", "fontWeight": "bold"},
),
html.Label("Select up to 3 waveforms:"),
dcc.Dropdown(
id="waveform-dropdown",
options=[
{"label": col, "value": col} for col in waveform_columns
],
value=waveform_columns[0] if waveform_columns else None,
value=[waveform_columns[0]] if waveform_columns else [],
multi=True,
),
dcc.Graph(id="waveform-plot"),
]
Expand All @@ -57,21 +86,63 @@ def create_dash_app(df, file_path):
@app.callback(
Output("waveform-plot", "figure"), Input("waveform-dropdown", "value")
)
def update_graph(selected_waveform):
if selected_waveform is None or df.empty:
def update_graph(selected_waveforms):
if not selected_waveforms or df.empty:
return go.Figure()

y_data = pd.to_numeric(df[selected_waveform], errors="coerce")
trace = go.Scatter(
x=df["Time (s)"], y=y_data, mode="lines", name=selected_waveform
# Enforce maximum of 3 plots
if isinstance(selected_waveforms, str):
selected_waveforms = [selected_waveforms]

selected_waveforms = selected_waveforms[:3]
num_plots = len(selected_waveforms)

fig = make_subplots(
rows=num_plots,
cols=1,
shared_xaxes=True,
vertical_spacing=0.05,
subplot_titles=selected_waveforms,
)
layout = go.Layout(

for i, waveform in enumerate(selected_waveforms, start=1):
y_data = pd.to_numeric(df[waveform], errors="coerce")

# Format absolute time for hover (hours:minutes:seconds.ms)
abs_time_str = (
df["Absolute_Time"].dt.strftime("%H:%M:%S.%f").str[:-3]
)

trace = go.Scatter(
x=df["Time (s)"],
y=y_data,
mode="lines",
name=waveform,
customdata=abs_time_str,
hovertemplate=(
"<b>%{y}</b><br>"
"Time: %{x:.2f} s<br>"
"Abs Time: %{customdata}"
"<extra></extra>"
),
)
fig.add_trace(trace, row=i, col=1)
fig.update_yaxes(title_text=waveform, row=i, col=1)

# Calculate dynamic height (approx 300px per plot)
plot_height = max(400, 300 * num_plots)

fig.update_layout(
height=plot_height,
xaxis={"title": "Time (s)"},
yaxis={"title": selected_waveform},
margin={"l": 50, "r": 10, "t": 40, "b": 50},
hovermode="closest",
hovermode="x unified",
)
return {"data": [trace], "layout": layout}

# Ensure the bottom-most x-axis has a title
fig.update_xaxes(title_text="Time (s)", row=num_plots, col=1)

return fig

return app

Expand Down
19 changes: 14 additions & 5 deletions tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@
def sample_dataframe():
"""Fixture for a sample dataframe."""
data = {
"Date_Time": [0, 1, 2, 3],
"Date_Time": [44917.617693, 44917.617705, 44917.617716, 44917.617728],
"Time (s)": [0, 1, 2, 3],
"Waveform1": [10, 20, 30, 40],
"Waveform2": [5, 15, 25, 35],
"Absolute_Time": pd.to_datetime(
[44917.617693, 44917.617705, 44917.617716, 44917.617728],
unit="D",
origin="1899-12-30",
),
}
return pd.DataFrame(data)

Expand All @@ -37,8 +42,9 @@ def test_create_dash_app(sample_dataframe):
in app.layout.children[0].children
)
assert f"Loaded file: {file_path}" in app.layout.children[1].children
assert app.layout.children[3].id == "waveform-dropdown"
assert app.layout.children[4].id == "waveform-plot"
assert app.layout.children[2].id == "start-time-info"
assert app.layout.children[4].id == "waveform-dropdown"
assert app.layout.children[5].id == "waveform-plot"


@patch("src.lung_utils.hamilton_ventilator.waveform_plotter.dcc.Dropdown")
Expand All @@ -60,13 +66,16 @@ def test_create_dash_app_layout(mock_graph, mock_dropdown, sample_dataframe):
== "Hamilton Ventilator Waveform Viewer"
)
assert app.layout.children[1].children == f"Loaded file: {file_path}"
assert app.layout.children[2].children == "Select waveform:"
assert app.layout.children[2].id == "start-time-info"
assert "Recording Start Time:" in app.layout.children[2].children
assert app.layout.children[3].children == "Select up to 3 waveforms:"
mock_dropdown.assert_called_once_with(
id="waveform-dropdown",
options=[
{"label": "Waveform1", "value": "Waveform1"},
{"label": "Waveform2", "value": "Waveform2"},
],
value="Waveform1",
value=["Waveform1"],
multi=True,
)
mock_graph.assert_called_once_with(id="waveform-plot")
Loading