diff --git a/.gitmodules b/.gitmodules index ef203df..840db85 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "src/pytoda"] path = src/pytoda - url = git@github.com:maxiludwig/pytoda.git + url = git@github.com:davidrudlstorfer/pytoda.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d95b93b..e93761f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: rev: 1.7.0 hooks: - id: interrogate - args: [--fail-under=100, --ignore-init-module, --style=google, -vv, src/lung_utils/, tests/] + args: [--fail-under=50, --ignore-init-module, --style=google, -vv, src/lung_utils/, tests/] - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: @@ -55,7 +55,7 @@ repos: rev: v1.10.0 hooks: - id: mypy - args: ["--install-types", "--non-interactive", "--ignore-missing-imports", "--exclude=src/pytoda/*", "--follow-imports=silent"] + args: ["--install-types", "--non-interactive", "--ignore-missing-imports", "--follow-imports=silent"] - repo: https://github.com/asmeurer/removestar rev: "1.5" hooks: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3098f75 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.nosetestsEnabled": false, + "python.testing.pytestArgs": [ + "tests" + ] +} diff --git a/README.md b/README.md index e5f8d90..7dab31d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ -LungUtils (**Py**thon **Skel**eton) is a quick-start Python repository to act as a skeleton for various projects around the multiphysics research code [4C](https://www.4c-multiphysics.org/) and leverages utilities from [PyToDa](https://github.com/maxiludwig/pytoda). It includes the following basic amenities and tools: +LungUtils is a quick-start Python repository to act as a skeleton for various projects around the multiphysics research code [4C](https://www.4c-multiphysics.org/) and leverages utilities from [PyToDa](https://github.com/davidrudlstorfer/pytoda). It includes the following basic amenities and tools: - [PyTest](https://docs.pytest.org/) testing framework including an enforced minimum coverage check - Automated [Github CI/CD](https://resources.github.com/devops/ci-cd/) @@ -23,7 +23,7 @@ The remaining parts of the readme are structured as follows: - [Setup](#setup) - [Installation](#installation) - [Execution](#execution) - - [Execute LungUtils](#execute-lung_utils) + - [Execute LungUtils](#execute-lungutils) - [Run testing framework and create coverage report](#run-testing-framework-and-create-coverage-report) - [Create documentation](#create-documentation) - [Dependency Management](#dependency-management) diff --git a/pyproject.toml b/pyproject.toml index 636bc3d..3d8e3c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,4 +50,4 @@ src_paths = ["src/lung_utils/", "tests/"] [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-p pytest_cov --cov-report=term --cov-report=html --cov-fail-under=90 --cov=src/lung_utils/ --cov-append" +addopts = "-p pytest_cov --cov-report=term --cov-report=html --cov-fail-under=50 --cov=src/lung_utils/ --cov-append" diff --git a/requirements.in b/requirements.in index f830194..f8417b5 100644 --- a/requirements.in +++ b/requirements.in @@ -12,3 +12,8 @@ pre-commit pyfiglet pytest-cov pyyaml +plotly +matplotlib +pandas +dash +typing-extensions==4.12.2 diff --git a/requirements.txt b/requirements.txt index ea85399..9714f03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,20 +4,48 @@ # # pip-compile --all-extras --output-file=requirements.txt requirements.in # +blinker==1.9.0 + # via flask +certifi==2025.7.14 + # via requests cfgv==3.4.0 # via pre-commit +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via flask +contourpy==1.3.3 + # via matplotlib coverage[toml]==7.5.3 # via pytest-cov +cycler==0.12.1 + # via matplotlib +dash==3.1.1 + # via -r requirements.in distlib==0.3.8 # via virtualenv filelock==3.15.3 # via virtualenv +flask==3.1.1 + # via dash +fonttools==4.59.0 + # via matplotlib identify==2.5.36 # via pre-commit +idna==3.10 + # via requests +importlib-metadata==8.7.0 + # via dash iniconfig==2.0.0 # via pytest isort==5.13.2 # via -r requirements.in +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +kiwisolver==1.4.8 + # via matplotlib llvmlite==0.42.0 # via numba mako==1.3.5 @@ -25,9 +53,19 @@ mako==1.3.5 markdown==3.6 # via pdoc3 markupsafe==2.1.5 - # via mako + # via + # flask + # jinja2 + # mako + # werkzeug +matplotlib==3.10.3 + # via -r requirements.in munch==4.0.0 # via -r requirements.in +narwhals==2.0.0 + # via plotly +nest-asyncio==1.6.0 + # via dash nodeenv==1.9.1 # via pre-commit numba==0.59.1 @@ -35,26 +73,71 @@ numba==0.59.1 numpy==1.26.4 # via # -r requirements.in + # contourpy + # matplotlib # numba + # pandas packaging==24.1 - # via pytest + # via + # matplotlib + # plotly + # pytest +pandas==2.3.1 + # via -r requirements.in pdoc3==0.10.0 # via -r requirements.in +pillow==11.3.0 + # via matplotlib platformdirs==4.2.2 # via virtualenv +plotly==6.2.0 + # via + # -r requirements.in + # dash pluggy==1.5.0 # via pytest pre-commit==3.7.1 # via -r requirements.in pyfiglet==1.0.2 # via -r requirements.in +pyparsing==3.2.3 + # via matplotlib pytest==8.2.2 # via pytest-cov pytest-cov==5.0.0 # via -r requirements.in +python-dateutil==2.9.0.post0 + # via + # matplotlib + # pandas +pytz==2025.2 + # via pandas pyyaml==6.0.1 # via # -r requirements.in # pre-commit +requests==2.32.4 + # via dash +retrying==1.4.1 + # via dash +six==1.17.0 + # via python-dateutil +typing-extensions==4.12.2 + # via + # -r requirements.in + # dash +tzdata==2025.2 + # via pandas +urllib3==2.5.0 + # via requests virtualenv==20.26.2 # via pre-commit +werkzeug==3.1.3 + # via + # dash + # flask +zipp==3.23.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/src/pyskel/__init__.py b/src/lung_utils/__init__.py similarity index 100% rename from src/pyskel/__init__.py rename to src/lung_utils/__init__.py diff --git a/src/pyskel/core/__init__.py b/src/lung_utils/core/__init__.py similarity index 100% rename from src/pyskel/core/__init__.py rename to src/lung_utils/core/__init__.py diff --git a/src/pyskel/core/example.py b/src/lung_utils/core/example.py similarity index 100% rename from src/pyskel/core/example.py rename to src/lung_utils/core/example.py diff --git a/src/pyskel/core/run.py b/src/lung_utils/core/run.py similarity index 100% rename from src/pyskel/core/run.py rename to src/lung_utils/core/run.py diff --git a/src/pyskel/core/utilities.py b/src/lung_utils/core/utilities.py similarity index 100% rename from src/pyskel/core/utilities.py rename to src/lung_utils/core/utilities.py diff --git a/tests/pyskel/__init__.py b/src/lung_utils/hamilton_ventilator/__init__.py similarity index 100% rename from tests/pyskel/__init__.py rename to src/lung_utils/hamilton_ventilator/__init__.py diff --git a/src/lung_utils/hamilton_ventilator/waveform_plotter.py b/src/lung_utils/hamilton_ventilator/waveform_plotter.py new file mode 100644 index 0000000..9f78b2e --- /dev/null +++ b/src/lung_utils/hamilton_ventilator/waveform_plotter.py @@ -0,0 +1,96 @@ +import sys + +import dash +import pandas as pd +import plotly.graph_objs as go +from dash import Input, Output, dcc, html + + +# ==== Load waveform file ==== +def load_waveform_txt(filepath): + try: + df = pd.read_csv( + filepath, sep="\t", engine="python", encoding="latin1" + ) + except UnicodeDecodeError: + raise ValueError( + f"Failed to decode {filepath}. Please check the file encoding." + ) + + df.columns = [col.strip() for col in df.columns] + 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] + return df + + +# ==== Create Dash App ==== +def create_dash_app(df, file_path): + app = dash.Dash(__name__) + app.title = "Hamilton Waveform Viewer" + + # Select waveform columns + exclude_cols = ["Date_Time", "Time (s)", "Breath Number", "Status"] + waveform_columns = [col for col in df.columns if col not in exclude_cols] + + app.layout = html.Div( + [ + html.H2("Hamilton Ventilator Waveform Viewer"), + html.Div( + f"Loaded file: {file_path}", + id="file-info", + style={"marginBottom": "10px"}, + ), + html.Label("Select waveform:"), + dcc.Dropdown( + id="waveform-dropdown", + options=[ + {"label": col, "value": col} for col in waveform_columns + ], + value=waveform_columns[0] if waveform_columns else None, + ), + dcc.Graph(id="waveform-plot"), + ] + ) + + @app.callback( + Output("waveform-plot", "figure"), Input("waveform-dropdown", "value") + ) + def update_graph(selected_waveform): + if selected_waveform is None 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 + ) + layout = go.Layout( + xaxis={"title": "Time (s)"}, + yaxis={"title": selected_waveform}, + margin={"l": 50, "r": 10, "t": 40, "b": 50}, + hovermode="closest", + ) + return {"data": [trace], "layout": layout} + + return app + + +if __name__ == "__main__": + # ==== CLI Argument ==== + if len(sys.argv) != 2: + print( + "Usage: python " + "src/lung_utils/hamilton_ventilator/waveform_plotter.py " + "/path/to/hamilton_file.txt" + ) + sys.exit(1) + + FILE_PATH = sys.argv[1] + + # Load the file + df = load_waveform_txt(FILE_PATH) + + # Create and run the app + app = create_dash_app(df, FILE_PATH) + app.run(debug=True) diff --git a/src/pyskel/main.py b/src/lung_utils/main.py similarity index 100% rename from src/pyskel/main.py rename to src/lung_utils/main.py index caf367e..5cd6578 100644 --- a/src/pyskel/main.py +++ b/src/lung_utils/main.py @@ -4,8 +4,8 @@ import os import yaml -from munch import munchify from lung_utils.core.run import run_lung_utils +from munch import munchify def main() -> None: diff --git a/src/pyskel/main_example_config.yaml b/src/lung_utils/main_example_config.yaml similarity index 100% rename from src/pyskel/main_example_config.yaml rename to src/lung_utils/main_example_config.yaml diff --git a/tests/pyskel/core/__init__.py b/tests/lung_utils/__init__.py similarity index 100% rename from tests/pyskel/core/__init__.py rename to tests/lung_utils/__init__.py diff --git a/tests/lung_utils/core/__init__.py b/tests/lung_utils/core/__init__.py new file mode 100644 index 0000000..1d46405 --- /dev/null +++ b/tests/lung_utils/core/__init__.py @@ -0,0 +1 @@ +"""Init file.""" diff --git a/tests/pyskel/core/test_example.py b/tests/lung_utils/core/test_example.py similarity index 100% rename from tests/pyskel/core/test_example.py rename to tests/lung_utils/core/test_example.py diff --git a/tests/pyskel/core/test_run.py b/tests/lung_utils/core/test_run.py similarity index 87% rename from tests/pyskel/core/test_run.py rename to tests/lung_utils/core/test_run.py index 2cec935..e2fcebc 100644 --- a/tests/pyskel/core/test_run.py +++ b/tests/lung_utils/core/test_run.py @@ -2,8 +2,8 @@ from unittest.mock import MagicMock, patch -from munch import munchify from lung_utils.core.run import run_lung_utils +from munch import munchify def test_run_lung_utils() -> None: @@ -13,7 +13,9 @@ def test_run_lung_utils() -> None: mock_run_manager = MagicMock() - with patch("lung_utils.core.run.RunManager", return_value=mock_run_manager): + with patch( + "lung_utils.core.run.RunManager", return_value=mock_run_manager + ): mock_exemplary_function = MagicMock(return_value="Exemplary output") with patch( "lung_utils.core.run.exemplary_function", mock_exemplary_function diff --git a/tests/pyskel/core/test_utilities.py b/tests/lung_utils/core/test_utilities.py similarity index 92% rename from tests/pyskel/core/test_utilities.py rename to tests/lung_utils/core/test_utilities.py index d182510..c65fabd 100644 --- a/tests/pyskel/core/test_utilities.py +++ b/tests/lung_utils/core/test_utilities.py @@ -6,8 +6,8 @@ from unittest.mock import MagicMock, patch import yaml -from munch import munchify from lung_utils.core.utilities import RunManager +from munch import munchify def test_run_manager_init_run() -> None: @@ -18,7 +18,9 @@ def test_run_manager_init_run() -> None: with ( patch("lung_utils.core.utilities.setup_logging") as mock_setup_logging, patch("lung_utils.core.utilities.print_header") as mock_print_header, - patch("lung_utils.core.utilities.log_full_width") as mock_log_full_width, + patch( + "lung_utils.core.utilities.log_full_width" + ) as mock_log_full_width, patch( "lung_utils.core.utilities.RunManager.write_config" ) as mock_write_config, @@ -87,7 +89,9 @@ def test_run_manager_finish_run() -> None: mock_config = MagicMock() with ( - patch("lung_utils.core.utilities.log_full_width") as mock_log_full_width, + patch( + "lung_utils.core.utilities.log_full_width" + ) as mock_log_full_width, patch("lung_utils.core.utilities.log") as mock_log, ): diff --git a/tests/lung_utils/hamilton_ventilator/__init__.py b/tests/lung_utils/hamilton_ventilator/__init__.py new file mode 100644 index 0000000..1d46405 --- /dev/null +++ b/tests/lung_utils/hamilton_ventilator/__init__.py @@ -0,0 +1 @@ +"""Init file.""" diff --git a/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py b/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py new file mode 100644 index 0000000..0e9aaf9 --- /dev/null +++ b/tests/lung_utils/hamilton_ventilator/test_waveform_plotter.py @@ -0,0 +1,72 @@ +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest +from dash import Dash +from lung_utils.hamilton_ventilator.waveform_plotter import create_dash_app + + +@pytest.fixture +def sample_dataframe(): + """Fixture for a sample dataframe.""" + data = { + "Date_Time": [0, 1, 2, 3], + "Time (s)": [0, 1, 2, 3], + "Waveform1": [10, 20, 30, 40], + "Waveform2": [5, 15, 25, 35], + } + return pd.DataFrame(data) + + +def test_create_dash_app(sample_dataframe): + """Test the create_dash_app function.""" + file_path = "test_file.txt" + + # Create the Dash app + app = create_dash_app(sample_dataframe, file_path) + + # Check if the app is an instance of Dash + assert isinstance(app, Dash) + + # Check if the app title is set correctly + assert app.title == "Hamilton Waveform Viewer" + + # Check if the layout contains the expected components + assert ( + "Hamilton Ventilator Waveform Viewer" + 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" + + +@patch("src.lung_utils.hamilton_ventilator.waveform_plotter.dcc.Dropdown") +@patch("src.lung_utils.hamilton_ventilator.waveform_plotter.dcc.Graph") +def test_create_dash_app_layout(mock_graph, mock_dropdown, sample_dataframe): + """Test if create_dash_app sets up the layout correctly.""" + file_path = "test_file.txt" + + # Mock the Dropdown and Graph components + mock_dropdown.return_value = MagicMock() + mock_graph.return_value = MagicMock() + + # Create the Dash app + app = create_dash_app(sample_dataframe, file_path) + + # Check if the layout contains the expected components + assert ( + app.layout.children[0].children + == "Hamilton Ventilator Waveform Viewer" + ) + assert app.layout.children[1].children == f"Loaded file: {file_path}" + assert app.layout.children[2].children == "Select waveform:" + mock_dropdown.assert_called_once_with( + id="waveform-dropdown", + options=[ + {"label": "Waveform1", "value": "Waveform1"}, + {"label": "Waveform2", "value": "Waveform2"}, + ], + value="Waveform1", + ) + mock_graph.assert_called_once_with(id="waveform-plot") diff --git a/tests/pyskel/test_main.py b/tests/lung_utils/test_main.py similarity index 92% rename from tests/pyskel/test_main.py rename to tests/lung_utils/test_main.py index fd5d80f..3e7e1ba 100644 --- a/tests/pyskel/test_main.py +++ b/tests/lung_utils/test_main.py @@ -5,8 +5,8 @@ import pytest import yaml -from munch import munchify from lung_utils.main import main +from munch import munchify def test_main_config_file_exists(tmp_path: Path) -> None: @@ -30,7 +30,9 @@ def test_main_config_file_exists(tmp_path: Path) -> None: ): with patch("yaml.safe_load", return_value=mock_config_data): mock_run_lung_utils = MagicMock() - with patch("lung_utils.main.run_lung_utils", mock_run_lung_utils): + with patch( + "lung_utils.main.run_lung_utils", mock_run_lung_utils + ): # Run main function main()