diff --git a/src/lung_utils/hamilton_ventilator/waveform_plotter.py b/src/lung_utils/hamilton_ventilator/waveform_plotter.py
index 9f78b2e..35144e9 100644
--- a/src/lung_utils/hamilton_ventilator/waveform_plotter.py
+++ b/src/lung_utils/hamilton_ventilator/waveform_plotter.py
@@ -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 ====
@@ -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
@@ -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"),
]
@@ -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=(
+ "%{y}
"
+ "Time: %{x:.2f} s
"
+ "Abs Time: %{customdata}"
+ ""
+ ),
+ )
+ 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
diff --git a/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py b/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py
index 0e9aaf9..59a2a28 100644
--- a/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py
+++ b/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py
@@ -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)
@@ -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")
@@ -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")