From f9db8106e86976cfb118978dabf136c5f2d4763e Mon Sep 17 00:00:00 2001 From: Maximilian Ludwig Date: Wed, 22 Apr 2026 15:07:46 +0200 Subject: [PATCH 1/2] feat: unified waveform plotter with up to 3 subplots --- .../hamilton_ventilator/waveform_plotter.py | 50 +++++++++++++++---- .../test_waveform_plotter.py | 5 +- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/lung_utils/hamilton_ventilator/waveform_plotter.py b/src/lung_utils/hamilton_ventilator/waveform_plotter.py index 9f78b2e..5fd3f3a 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 ==== @@ -42,13 +43,14 @@ def create_dash_app(df, file_path): id="file-info", style={"marginBottom": "10px"}, ), - html.Label("Select waveform:"), + 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 +59,47 @@ 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") + trace = go.Scatter( + x=df["Time (s)"], y=y_data, mode="lines", name=waveform + ) + 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..f499c5b 100644 --- a/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py +++ b/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py @@ -60,13 +60,14 @@ 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].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") From a58483ae78b76ae3b1d7d180fd00dcdec3f1be04 Mon Sep 17 00:00:00 2001 From: Maximilian Ludwig Date: Wed, 22 Apr 2026 15:32:54 +0200 Subject: [PATCH 2/2] feat: display real time from hamilton dates --- .../hamilton_ventilator/waveform_plotter.py | 51 +++++++++++++++++-- .../test_waveform_plotter.py | 16 ++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/lung_utils/hamilton_ventilator/waveform_plotter.py b/src/lung_utils/hamilton_ventilator/waveform_plotter.py index 5fd3f3a..35144e9 100644 --- a/src/lung_utils/hamilton_ventilator/waveform_plotter.py +++ b/src/lung_utils/hamilton_ventilator/waveform_plotter.py @@ -22,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 @@ -32,16 +41,34 @@ 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.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( @@ -80,8 +107,24 @@ def update_graph(selected_waveforms): 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 + 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) diff --git a/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py b/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py index f499c5b..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,7 +66,9 @@ 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 up to 3 waveforms:" + 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=[