diff --git a/.gitignore b/.gitignore index ecf0a5b90c327685bef39b9fd76b075618687b9f..26331733cd987d6316851e853cd400c7631ecfba 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ venv/ ENV/ env/ + + ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..dc4c575fe1343ac222be57a9d056e0b766d01183 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +exclude lifescale/models/ls_run.py lifescale/scripts/run_gui.py \ No newline at end of file diff --git a/README.md b/README.md index 49c7c8b3c85f91445e50bf9b241dd809f4d2c571..790f66901c4fb10be2aa7dbc48e4712db4409f8d 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,52 @@ -# lifescale_gui +# lifescale_utils Data analysis tools for lifescale with GUI. -# Installation and setup -* **1. Create virtual environment (venv)** - * `python3 -m venv env` -* **2. Activate virtual environment** - * `source env/bin/activate` -* **3. Clone git repository to local machine** - * `git clone git@gitlab.com:hellerdev/lifescale_gui.git` - * `cd lifescale_gui` -* **4. Install required python packages using pip** - * `pip install -r requirements.txt` - - -## Installation issues on Ubuntu (20.04): -After just installing PyQt5 with pip3 the following error occurred when trying to actually run a PyQt GUI: qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "" even though it was found. -This issue was resolved by installing the QT dev tools (Designer, etc.): -sudo apt-get install qttools5-dev-tools - -# Test installation with setuptools -With command line interface. - -* **1. Configure setup.py** - * Define entry points (*console_scripts*) -* **2. Activate virtual environment** - * e.g. `source env/bin/activate` -* **3. Run setup.py** - * `python3 setup.py develop` - -## Using make -`python3 setup.py develop` - -# Run application on Windows and create a stand-alone Windows executable file: -TODO - -# Comments on requirements.txt file: -* Two entries can be deleted: - * -e git+git@gitlab.com:Heller182/grav.git@fe528c0769502e84a06be67a742032cacfd386df#egg=gravtools - * pkg-resources==0.0.0 (created due a bug when using Linux, see: https://stackoverflow.com/questions/39577984/what-is-pkg-resources-0-0-0-in-output-of-pip-freeze-command) - - -# Create HTML documentation with sphinx: -Run make in the gravtools/doc directory: -* `>>>make html_doc` - -# Guidelines and conventions - -## Code style: -* Respect the PEP conventions on python coding! - * PEP 8 -- Style Guide for Python Code: https://www.python.org/dev/peps/pep-0008/ -* The maximum line length is 120 characters -* Use **type hints**: https://www.python.org/dev/peps/pep-0484/ -* Use docstrings according to the numpy standard: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard - * They are useful to generate the documentation automatically - * Example: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html -* Comment code, if necessary! -* Use English language for the code, docstrings and comments - * German is allowed for user interfaces (GUI, command line), although English is preferred - -## Documentation and docstring style -* The API reference is created with sphinx (https://www.sphinx-doc.org/). -* Docstrings have to follow the numpy standard, see: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard - * Examples: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html -* Package documentation via docstring in __ini__.py files -* Module documentation via docstring at first lines of py-file -* Documentation of classes, class methods and functions via docstrings - -## Command line interface and executable scripts -* The command line interface is realized via entry points (console_scripts) in setuptools (python packaging tool) - * Input arguments are handled with argparse - * The code is located in the command_line module (gravtools/command_line.py) -* Executable scripts are located in gravtools/scripts - -## Dependancies -* Required python packages are listed in requirements.txt - * created with `>>>pip freeze > requirements.txt` - -## Version control with GIT -* Gitlab repository: https://gitlab.com/Heller182/grav -* Branching model: - * **master** branch: Current release version - * **develop** branch: Current working version. - * All team members merge their feature branches into develop (merge request via gitlab) - * Make sure that the develop branch contains a fully functional version of the code! - * **feature** branches: Branches of develop for the implementation of new features and other changes. - * Code changes only in feature branches! - * Naming convention: feature_<description of change/feature>, e.g. feature_new_tide_model -* Use gitignore files to prevent any data files (except example files), IDE control files, compiled python code, etc. from being stored in the GIT repository - * Generally rule: Ignore everything in a directory and define explicit exceptions! - -## Packaging and distribution -* With setuptools +# Command line programs: + +## ls2csv +The program *ls2csv* reads the content of the xlsm files written by lifescale units, parses the data and writes them to three csv +files (where `[run-name]` is the name from the settings sheet): + * `Masses_Vibrio_[run-name].csv`: Data series from the sheet AcquisitionIntervals. + * `Metadata_[run-name].csv`: Data from the sheet PanelData. + * `SampleSummary_[run-name].csv`: Data from the sheet IntervalAnalysis plus sample related data from AcquisitionIntervals. + +### Usage: +``` +ls2csv -i [path and nale of xlsm file] -o [outpur directory] [-s] [-nv] + +options: + -h, --help show this help message and exit + -i INPUT_XLSM, --input-xlsm INPUT_XLSM + Path and name of the input xlsm file created by + lifescale. (default: None) + -o OUT_DIR, --out-dir OUT_DIR + Output directory for the CSV files. (default: None) + -nv, --not-verbose Disable command line status messages. (default: False) + -s, --sample-stats Calculate sample statistics of masses (median, std. + deviation, quartiles, interquartile range) and add + them to the SampleSummary output CSV file (columns: + Mass_median, Mass_std, Mass_q25, Mass_q75,Mass_iqr). + (default: False) + -t, --sort-masses-by-time + Sort data in the Masses CSV file by acquisition time. + (default: False) +``` + + +# License and copyright + +Copyright (C) 2022 Andreas Hellerschmied (<heller182@gmx.at>) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. diff --git a/info/dev_notes.txt b/info/dev_notes.txt index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3dac3d743daa7f04244042b0ad23544e4f0fe848 100644 --- a/info/dev_notes.txt +++ b/info/dev_notes.txt @@ -0,0 +1,47 @@ +# Current pgm: + +## Input data (naive_peaks.py): + - raw data files: binary data (required) + - metadata: csv file that holds the information of the PanelData tab in the according .xlsm file + - jason file with settings: + - raw data folder + - processing settings + +# Questions: + + - Which license? + - Is it OK for Joseph Elsherbini to use/change his code? + + - Is a "raw data viewer" needed to show the content of the binary raw data files? + - Individual files, or from one experiment? + - Load to pandas DF and show in table in GUI? + - Is editing required? + - Are any search options needed? + - Format conversion, e.g. to csv files needed? + + - Should there be the possibility to store the current GUI settings (data and processing parameters, etc.) + - Should there be the possibility to load default parameters and/or previously saved parameters (e.g. from json file)? + - Is the json file required in the current version, e.g. for documentation (processing parameters, etc.) or further analysis? + - Are there any default values for the processing parameters? Should they be stored anywhere (e.g. json file)? + - Standard values defined in pgm code (e.g. parameters.py file). These parameters are loaded on the initial start + - Save/Load parameters from GUI to json file => Define "standard parameter set" that way + +## Processing: + - Is the input data (raw data) always organized in the same way, i.e.: Raw data folder that contains data files with the + file names containing the exper. name date and time? + - Where do we get the following parameters (listed in drop down menu) - from the filenames? + - Experiment name + - Date + - Time + - should directly the .xlsm file (PanelData tab) be used instead of an .csv file that holds identical information? + - What is the actual output of the lifescale devise? + - raw data? + - xlsm files with metadata? + +## For plotting and analysis: + - Is additional data required from other sources, e.g. the .xlsm files? + - What should be plotted/analyzed/calculated? + + + + diff --git a/lifescale/__init__.py b/lifescale/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..af1d517ca89f1f94b3c97b63573cf5f95052ea4e --- /dev/null +++ b/lifescale/__init__.py @@ -0,0 +1,24 @@ +"""LifeScale utils is a utility program for handling data output. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. + +:Authors: + Andreas Hellerschmied (heller182@gmx.at) +""" + +__version__ = '0.0.3' +__author__ = 'Andreas Hellerschmied' +__git_repo__ = 'tba' +__email__ = 'heller182@gmx.at' +__copyright__ = '(c) 2022 Andreas Hellerschmied' diff --git a/lifescale/command_line/__init__.py b/lifescale/command_line/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..522bb39b3759f7a7c8067bca09a9dc391d2aa9a4 --- /dev/null +++ b/lifescale/command_line/__init__.py @@ -0,0 +1,18 @@ +"""LifeScale utils command line interface module. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. + +:Authors: + Andreas Hellerschmied (heller182@gmx.at) +""" \ No newline at end of file diff --git a/lifescale/command_line/command_line.py b/lifescale/command_line/command_line.py new file mode 100644 index 0000000000000000000000000000000000000000..86eff8c7e93ef09e30808abb4131271f212586ac --- /dev/null +++ b/lifescale/command_line/command_line.py @@ -0,0 +1,75 @@ +"""Command line interface of lifescale utils. + +Copyright (C) 2022 Andreas Hellerschmied <heller182@gmx.at> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +""" + +from lifescale.scripts.ls2csv import ls2csv as ls2csv_main +import argparse +import os + + +def is_file(filename): + """Check, whether the input string is the path to an existing file.""" + if os.path.isfile(filename): + return filename + raise argparse.ArgumentTypeError("'{}' is not a valid file.".format(filename)) + + +def is_dir(pathname): + """Check, whether the input string is a valid and existing filepath.""" + if os.path.exists(pathname): + return pathname + raise argparse.ArgumentTypeError("'{}' is not a valid directory.".format(pathname)) + + +def ls2csv(): + """Command line interface including argument parser for the lifescale2csv converter.""" + parser = argparse.ArgumentParser(prog="ls2csv", + description="Conversion from lifescale xlsm output to csv files", + epilog="The ls2csv converter loads and parses xlsm files created by the lifescale " + "unit. It writes several csv files to the output directory that contain " + "extracted data from the input xlsm file in an easily readable way.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument("-i", "--input-xlsm", type=is_file, required=True, help="Path and name of the input xlsm file " + "created by " + "lifescale.") + parser.add_argument("-o", "--out-dir", type=is_dir, required=True, help="Output directory for the CSV files.") + parser.add_argument("-nv", "--not-verbose", required=False, help="Disable command line status messages.", + action='store_true') + parser.add_argument("-s", "--sample-stats", required=False, help="Calculate sample statistics of masses (median, " + "std. deviation, quartiles, interquartile range) " + "and add them to the " + "SampleSummary output CSV file (columns: " + "Mass_median, Mass_std, Mass_q25, Mass_q75," + "Mass_iqr).", + action='store_true') + parser.add_argument("-t", "--sort-masses-by-time", required=False, help="Sort data in the Masses CSV file by " + "acquisition time.", + action='store_true') + + args = parser.parse_args() + verbose = not args.not_verbose + + return ls2csv_main(xlsm_filename=args.input_xlsm, + output_dir=args.out_dir, + sample_stats=args.sample_stats, + sort_by_time=args.sort_masses_by_time, + verbose=verbose) + + +if __name__ == '__main__': + """Main function for debugging and testing.""" + ls2csv() diff --git a/lifescale/gui/MainWindow.py b/lifescale/gui/MainWindow.py new file mode 100644 index 0000000000000000000000000000000000000000..e1d6bff8ecfb591c724f5d042341995d0d985ac8 --- /dev/null +++ b/lifescale/gui/MainWindow.py @@ -0,0 +1,161 @@ +# Form implementation generated from reading ui file 'lifescale/gui/MainWindow.ui' +# +# Created by: PyQt6 UI code generator 6.2.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(992, 616) + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) + self.verticalLayout.setObjectName("verticalLayout") + self.tabWidget_Main = QtWidgets.QTabWidget(self.centralwidget) + self.tabWidget_Main.setObjectName("tabWidget_Main") + self.tab_peaks = QtWidgets.QWidget() + self.tab_peaks.setObjectName("tab_peaks") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.tab_peaks) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.groupBox_data = QtWidgets.QGroupBox(self.tab_peaks) + self.groupBox_data.setObjectName("groupBox_data") + self.formLayout = QtWidgets.QFormLayout(self.groupBox_data) + self.formLayout.setObjectName("formLayout") + self.label_data_rawData = QtWidgets.QLabel(self.groupBox_data) + self.label_data_rawData.setObjectName("label_data_rawData") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_data_rawData) + self.horizontalLayout_data_rawData = QtWidgets.QHBoxLayout() + self.horizontalLayout_data_rawData.setObjectName("horizontalLayout_data_rawData") + self.lineEdit_data_rawData = QtWidgets.QLineEdit(self.groupBox_data) + self.lineEdit_data_rawData.setObjectName("lineEdit_data_rawData") + self.horizontalLayout_data_rawData.addWidget(self.lineEdit_data_rawData) + self.pushButton_data_rawData = QtWidgets.QPushButton(self.groupBox_data) + self.pushButton_data_rawData.setObjectName("pushButton_data_rawData") + self.horizontalLayout_data_rawData.addWidget(self.pushButton_data_rawData) + self.formLayout.setLayout(0, QtWidgets.QFormLayout.ItemRole.FieldRole, self.horizontalLayout_data_rawData) + self.label_data_metadataFile = QtWidgets.QLabel(self.groupBox_data) + self.label_data_metadataFile.setObjectName("label_data_metadataFile") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_data_metadataFile) + self.horizontalLayout_data_metadataFile = QtWidgets.QHBoxLayout() + self.horizontalLayout_data_metadataFile.setObjectName("horizontalLayout_data_metadataFile") + self.lineEdit_data_metadataFile = QtWidgets.QLineEdit(self.groupBox_data) + self.lineEdit_data_metadataFile.setObjectName("lineEdit_data_metadataFile") + self.horizontalLayout_data_metadataFile.addWidget(self.lineEdit_data_metadataFile) + self.pushButton_data_metadataFile = QtWidgets.QPushButton(self.groupBox_data) + self.pushButton_data_metadataFile.setObjectName("pushButton_data_metadataFile") + self.horizontalLayout_data_metadataFile.addWidget(self.pushButton_data_metadataFile) + self.formLayout.setLayout(1, QtWidgets.QFormLayout.ItemRole.FieldRole, self.horizontalLayout_data_metadataFile) + self.label_data_outputFolder = QtWidgets.QLabel(self.groupBox_data) + self.label_data_outputFolder.setObjectName("label_data_outputFolder") + self.formLayout.setWidget(2, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_data_outputFolder) + self.horizontalLayout_data_outputFolder = QtWidgets.QHBoxLayout() + self.horizontalLayout_data_outputFolder.setObjectName("horizontalLayout_data_outputFolder") + self.lineEdit_data_outputFolder = QtWidgets.QLineEdit(self.groupBox_data) + self.lineEdit_data_outputFolder.setObjectName("lineEdit_data_outputFolder") + self.horizontalLayout_data_outputFolder.addWidget(self.lineEdit_data_outputFolder) + self.pushButton_data_outputFolder = QtWidgets.QPushButton(self.groupBox_data) + self.pushButton_data_outputFolder.setObjectName("pushButton_data_outputFolder") + self.horizontalLayout_data_outputFolder.addWidget(self.pushButton_data_outputFolder) + self.formLayout.setLayout(2, QtWidgets.QFormLayout.ItemRole.FieldRole, self.horizontalLayout_data_outputFolder) + self.label_data_selectExperiment = QtWidgets.QLabel(self.groupBox_data) + self.label_data_selectExperiment.setObjectName("label_data_selectExperiment") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_data_selectExperiment) + self.comboBox_data_selectExperiment = QtWidgets.QComboBox(self.groupBox_data) + self.comboBox_data_selectExperiment.setObjectName("comboBox_data_selectExperiment") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.ItemRole.FieldRole, self.comboBox_data_selectExperiment) + self.verticalLayout_2.addWidget(self.groupBox_data) + self.groupBox_processingParameters = QtWidgets.QGroupBox(self.tab_peaks) + self.groupBox_processingParameters.setObjectName("groupBox_processingParameters") + self.formLayout_2 = QtWidgets.QFormLayout(self.groupBox_processingParameters) + self.formLayout_2.setObjectName("formLayout_2") + self.label_massTransformation = QtWidgets.QLabel(self.groupBox_processingParameters) + self.label_massTransformation.setObjectName("label_massTransformation") + self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_massTransformation) + self.doubleSpinBox_massTransformation = QtWidgets.QDoubleSpinBox(self.groupBox_processingParameters) + self.doubleSpinBox_massTransformation.setDecimals(6) + self.doubleSpinBox_massTransformation.setObjectName("doubleSpinBox_massTransformation") + self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.ItemRole.FieldRole, self.doubleSpinBox_massTransformation) + self.label_massCutoff = QtWidgets.QLabel(self.groupBox_processingParameters) + self.label_massCutoff.setObjectName("label_massCutoff") + self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_massCutoff) + self.doubleSpinBox_massCutoff = QtWidgets.QDoubleSpinBox(self.groupBox_processingParameters) + self.doubleSpinBox_massCutoff.setDecimals(1) + self.doubleSpinBox_massCutoff.setObjectName("doubleSpinBox_massCutoff") + self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.ItemRole.FieldRole, self.doubleSpinBox_massCutoff) + self.label_peakWidthCutoff = QtWidgets.QLabel(self.groupBox_processingParameters) + self.label_peakWidthCutoff.setObjectName("label_peakWidthCutoff") + self.formLayout_2.setWidget(2, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_peakWidthCutoff) + self.doubleSpinBox_peakWidthCutoff = QtWidgets.QDoubleSpinBox(self.groupBox_processingParameters) + self.doubleSpinBox_peakWidthCutoff.setDecimals(1) + self.doubleSpinBox_peakWidthCutoff.setObjectName("doubleSpinBox_peakWidthCutoff") + self.formLayout_2.setWidget(2, QtWidgets.QFormLayout.ItemRole.FieldRole, self.doubleSpinBox_peakWidthCutoff) + self.label_peakDistanceCutoff = QtWidgets.QLabel(self.groupBox_processingParameters) + self.label_peakDistanceCutoff.setObjectName("label_peakDistanceCutoff") + self.formLayout_2.setWidget(3, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_peakDistanceCutoff) + self.doubleSpinBox_peakDistanceCutoff = QtWidgets.QDoubleSpinBox(self.groupBox_processingParameters) + self.doubleSpinBox_peakDistanceCutoff.setDecimals(1) + self.doubleSpinBox_peakDistanceCutoff.setObjectName("doubleSpinBox_peakDistanceCutoff") + self.formLayout_2.setWidget(3, QtWidgets.QFormLayout.ItemRole.FieldRole, self.doubleSpinBox_peakDistanceCutoff) + self.verticalLayout_2.addWidget(self.groupBox_processingParameters) + self.pushButton_run = QtWidgets.QPushButton(self.tab_peaks) + self.pushButton_run.setObjectName("pushButton_run") + self.verticalLayout_2.addWidget(self.pushButton_run) + self.tabWidget_Main.addTab(self.tab_peaks, "") + self.tab_analysis = QtWidgets.QWidget() + self.tab_analysis.setObjectName("tab_analysis") + self.tabWidget_Main.addTab(self.tab_analysis, "") + self.verticalLayout.addWidget(self.tabWidget_Main) + MainWindow.setCentralWidget(self.centralwidget) + self.menubar = QtWidgets.QMenuBar(MainWindow) + self.menubar.setGeometry(QtCore.QRect(0, 0, 992, 22)) + self.menubar.setObjectName("menubar") + self.menuOptions = QtWidgets.QMenu(self.menubar) + self.menuOptions.setObjectName("menuOptions") + MainWindow.setMenuBar(self.menubar) + self.statusbar = QtWidgets.QStatusBar(MainWindow) + self.statusbar.setObjectName("statusbar") + MainWindow.setStatusBar(self.statusbar) + self.actionSave_current_GUI_parameters_json = QtGui.QAction(MainWindow) + self.actionSave_current_GUI_parameters_json.setObjectName("actionSave_current_GUI_parameters_json") + self.actionLoad_GUI_parameters_json = QtGui.QAction(MainWindow) + self.actionLoad_GUI_parameters_json.setObjectName("actionLoad_GUI_parameters_json") + self.actionLoad_default_parameters = QtGui.QAction(MainWindow) + self.actionLoad_default_parameters.setObjectName("actionLoad_default_parameters") + self.menuOptions.addAction(self.actionSave_current_GUI_parameters_json) + self.menuOptions.addAction(self.actionLoad_GUI_parameters_json) + self.menuOptions.addAction(self.actionLoad_default_parameters) + self.menubar.addAction(self.menuOptions.menuAction()) + + self.retranslateUi(MainWindow) + self.tabWidget_Main.setCurrentIndex(0) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "lifescale tools")) + self.groupBox_data.setTitle(_translate("MainWindow", "Data")) + self.label_data_rawData.setText(_translate("MainWindow", "Raw data folder")) + self.pushButton_data_rawData.setText(_translate("MainWindow", "Browse")) + self.label_data_metadataFile.setText(_translate("MainWindow", "Metadata file (optional)")) + self.pushButton_data_metadataFile.setText(_translate("MainWindow", "Browse")) + self.label_data_outputFolder.setText(_translate("MainWindow", "Output folder")) + self.pushButton_data_outputFolder.setText(_translate("MainWindow", "Browse")) + self.label_data_selectExperiment.setText(_translate("MainWindow", "Select Experiment")) + self.groupBox_processingParameters.setTitle(_translate("MainWindow", "Processing parameters")) + self.label_massTransformation.setText(_translate("MainWindow", "Mass transformation [fg/Hz]")) + self.label_massCutoff.setText(_translate("MainWindow", "Mass cutoff [fg]")) + self.label_peakWidthCutoff.setText(_translate("MainWindow", "Peak width cutoff [???]")) + self.label_peakDistanceCutoff.setText(_translate("MainWindow", "Peak distance cutoff [??]")) + self.pushButton_run.setText(_translate("MainWindow", "Run")) + self.tabWidget_Main.setTabText(self.tabWidget_Main.indexOf(self.tab_peaks), _translate("MainWindow", "Mass Peaks")) + self.tabWidget_Main.setTabText(self.tabWidget_Main.indexOf(self.tab_analysis), _translate("MainWindow", "Analysis")) + self.menuOptions.setTitle(_translate("MainWindow", "Options")) + self.actionSave_current_GUI_parameters_json.setText(_translate("MainWindow", "Save current GUI parameters (json)")) + self.actionLoad_GUI_parameters_json.setText(_translate("MainWindow", "Load GUI parameters (json)")) + self.actionLoad_default_parameters.setText(_translate("MainWindow", "Load default parameters")) diff --git a/lifescale/gui/MainWindow.ui b/lifescale/gui/MainWindow.ui new file mode 100644 index 0000000000000000000000000000000000000000..8b01b72c078c0c7b11c1ad10d6185da8c5a62ca0 --- /dev/null +++ b/lifescale/gui/MainWindow.ui @@ -0,0 +1,231 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>MainWindow</class> + <widget class="QMainWindow" name="MainWindow"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>992</width> + <height>616</height> + </rect> + </property> + <property name="windowTitle"> + <string>lifescale tools</string> + </property> + <widget class="QWidget" name="centralwidget"> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QTabWidget" name="tabWidget_Main"> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="tab_peaks"> + <attribute name="title"> + <string>Mass Peaks</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QGroupBox" name="groupBox_data"> + <property name="title"> + <string>Data</string> + </property> + <layout class="QFormLayout" name="formLayout"> + <item row="0" column="0"> + <widget class="QLabel" name="label_data_rawData"> + <property name="text"> + <string>Raw data folder</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <layout class="QHBoxLayout" name="horizontalLayout_data_rawData"> + <item> + <widget class="QLineEdit" name="lineEdit_data_rawData"/> + </item> + <item> + <widget class="QPushButton" name="pushButton_data_rawData"> + <property name="text"> + <string>Browse</string> + </property> + </widget> + </item> + </layout> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label_data_metadataFile"> + <property name="text"> + <string>Metadata file (optional)</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <layout class="QHBoxLayout" name="horizontalLayout_data_metadataFile"> + <item> + <widget class="QLineEdit" name="lineEdit_data_metadataFile"/> + </item> + <item> + <widget class="QPushButton" name="pushButton_data_metadataFile"> + <property name="text"> + <string>Browse</string> + </property> + </widget> + </item> + </layout> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="label_data_outputFolder"> + <property name="text"> + <string>Output folder</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <layout class="QHBoxLayout" name="horizontalLayout_data_outputFolder"> + <item> + <widget class="QLineEdit" name="lineEdit_data_outputFolder"/> + </item> + <item> + <widget class="QPushButton" name="pushButton_data_outputFolder"> + <property name="text"> + <string>Browse</string> + </property> + </widget> + </item> + </layout> + </item> + <item row="3" column="0"> + <widget class="QLabel" name="label_data_selectExperiment"> + <property name="text"> + <string>Select Experiment</string> + </property> + </widget> + </item> + <item row="3" column="1"> + <widget class="QComboBox" name="comboBox_data_selectExperiment"/> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox_processingParameters"> + <property name="title"> + <string>Processing parameters</string> + </property> + <layout class="QFormLayout" name="formLayout_2"> + <item row="0" column="0"> + <widget class="QLabel" name="label_massTransformation"> + <property name="text"> + <string>Mass transformation [fg/Hz]</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QDoubleSpinBox" name="doubleSpinBox_massTransformation"> + <property name="decimals"> + <number>6</number> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label_massCutoff"> + <property name="text"> + <string>Mass cutoff [fg]</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QDoubleSpinBox" name="doubleSpinBox_massCutoff"> + <property name="decimals"> + <number>1</number> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="label_peakWidthCutoff"> + <property name="text"> + <string>Peak width cutoff [???]</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QDoubleSpinBox" name="doubleSpinBox_peakWidthCutoff"> + <property name="decimals"> + <number>1</number> + </property> + </widget> + </item> + <item row="3" column="0"> + <widget class="QLabel" name="label_peakDistanceCutoff"> + <property name="text"> + <string>Peak distance cutoff [??]</string> + </property> + </widget> + </item> + <item row="3" column="1"> + <widget class="QDoubleSpinBox" name="doubleSpinBox_peakDistanceCutoff"> + <property name="decimals"> + <number>1</number> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_run"> + <property name="text"> + <string>Run</string> + </property> + </widget> + </item> + </layout> + </widget> + <widget class="QWidget" name="tab_analysis"> + <attribute name="title"> + <string>Analysis</string> + </attribute> + </widget> + </widget> + </item> + </layout> + </widget> + <widget class="QMenuBar" name="menubar"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>992</width> + <height>22</height> + </rect> + </property> + <widget class="QMenu" name="menuOptions"> + <property name="title"> + <string>Options</string> + </property> + <addaction name="actionSave_current_GUI_parameters_json"/> + <addaction name="actionLoad_GUI_parameters_json"/> + <addaction name="actionLoad_default_parameters"/> + </widget> + <addaction name="menuOptions"/> + </widget> + <widget class="QStatusBar" name="statusbar"/> + <action name="actionSave_current_GUI_parameters_json"> + <property name="text"> + <string>Save current GUI parameters (json)</string> + </property> + </action> + <action name="actionLoad_GUI_parameters_json"> + <property name="text"> + <string>Load GUI parameters (json)</string> + </property> + </action> + <action name="actionLoad_default_parameters"> + <property name="text"> + <string>Load default parameters</string> + </property> + </action> + </widget> + <resources/> + <connections/> +</ui> diff --git a/lifescale_gui/__init__.py b/lifescale/gui/__init__.py similarity index 100% rename from lifescale_gui/__init__.py rename to lifescale/gui/__init__.py diff --git a/lifescale/gui/gui_main.py b/lifescale/gui/gui_main.py new file mode 100644 index 0000000000000000000000000000000000000000..4bfbecadb8f7a91011c94714f3e6c0adcc92de1f --- /dev/null +++ b/lifescale/gui/gui_main.py @@ -0,0 +1,40 @@ +import sys +import os +from PyQt6.QtWidgets import QApplication, QMainWindow + +from lifescale.gui.MainWindow import Ui_MainWindow + + +class MainWindow(QMainWindow, Ui_MainWindow): + """Main Window of the application.""" + + def __init__(self): + """Initializer.""" + + # GUI: + super().__init__() + self.setupUi(self) + + # Connect signals and slots: + + # Set up GUI items and widgets: + + # Init models: + + +def main(): + """Main program to start the GUI.""" + # Create the application + app = QApplication(sys.argv) + + # Create and show the application's main window + main_window = MainWindow() + main_window.show() + # Run the application's main loop: + sys.exit( + app.exec()) # exit or error code of Qt (app.exec_) is passed to sys.exit. Terminates pgm with standard python method + + +if __name__ == "__main__": + """Main Program.""" + main() diff --git a/lifescale/models/__init__.py b/lifescale/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c91b86962d614df51c822e711fc9453b6009996a --- /dev/null +++ b/lifescale/models/__init__.py @@ -0,0 +1,18 @@ +"""LifeScale utils objects module. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. + +:Authors: + Andreas Hellerschmied (heller182@gmx.at) +""" \ No newline at end of file diff --git a/lifescale/models/ls_data.py b/lifescale/models/ls_data.py new file mode 100644 index 0000000000000000000000000000000000000000..f60d18a735a5d31732a21cbc93f02f156ec09537 --- /dev/null +++ b/lifescale/models/ls_data.py @@ -0,0 +1,552 @@ +"""Modelling the data output of a LifeScale run. + +Copyright (C) 2022 Andreas Hellerschmied <heller182@gmx.at> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +""" + +import datetime as dt +import numpy as np +import os +import pandas as pd + + +class LSData: + """Modelling the data output of a LifeScale run. + + Attributes + ---------- + run_name : str + Name of the LifeScale run. + input_xlsm_filename : str + Filename and path of the xlsm file written by LifeScale. + output_dir_path : str + Output directory filepath. + start_time_dt : datetime object + Start time of run. + end_time_dt : datetime object + End time of the run. + settings_dict : dict + Contains all settings from the Settings sheet of the input xlsm file. If more than one attributes are provides + for a parameter (dictionary key), the dictionary item is a list. If no attribute is provided, the item is `None` + df_panel_data : pandas dataframe + Pandas dataframe that holds the data of the PanelData sheet of the input xlsm file. + df_interval_analysis : pandas dataframe + Pandas dataframe that holds the data of the IntervalAnalysis sheet plus additional data of the input xlsm file. + df_masses : pandas dataframe + Pandas dataframe that holds the data derived from the AcquisitionIntervals sheet of the input xlsm file. + """ + + def __init__(self, + run_name='', + guid='', + input_xlsm_filename='', + output_dir_path='', + start_time_dt=None, + end_time_dt=None, + settings_dict=None, + df_panel_data=None, + df_interval_analysis=None, + df_masses=None, + ): + """Default constructor of class LSData.""" + + # Check input arguments: + + # run_name and accession number: + if isinstance(run_name, str): + self.run_name = run_name + else: + raise TypeError('"run_name" needs to be a string') + if isinstance(guid, str): + self.guid = guid + else: + raise TypeError('"guid" needs to be a string') + + # output_dir_path: + if isinstance(output_dir_path, str): + self.output_dir_path = output_dir_path + else: + raise TypeError('"data_dir_path" needs to be a string') + + # xlsm_filename: + if isinstance(input_xlsm_filename, str): + self.input_xlsm_filename = input_xlsm_filename + else: + raise TypeError('"xlsm_filename" needs to be a string') + + # Start and end time of the run: + if isinstance(start_time_dt, dt.datetime): + self.start_time_dt = start_time_dt + else: + raise TypeError('"start_time_dt" needs to be a datetime object') + if isinstance(end_time_dt, dt.datetime): + self.end_time_dt = end_time_dt + else: + raise TypeError('"end_time_dt" needs to be a datetime object') + if isinstance(end_time_dt, dt.datetime): + self.end_time_dt = end_time_dt + else: + raise TypeError('"end_time_dt" needs to be a datetime object') + + # Settings dict: + if isinstance(settings_dict, dict): + self.settings_dict = settings_dict + else: + raise TypeError('"settings_dict" needs to be a dict.') + + # Dataframes: + if isinstance(df_panel_data, pd.DataFrame): + self.df_panel_data = df_panel_data + else: + raise TypeError('"df_panel_data" needs to be a pandas dataframe.') + if isinstance(df_interval_analysis, pd.DataFrame): + self.df_interval_analysis = df_interval_analysis + else: + raise TypeError('"df_interval_analysis" needs to be a pandas dataframe.') + if isinstance(df_masses, pd.DataFrame): + self.df_masses = df_masses + else: + raise TypeError('"df_masses" needs to be a pandas dataframe.') + + # Initialize additional attributes: + pass + + @classmethod + def from_xlsm_file(cls, input_xlsm_filename, verbose=True): + """Constructor that generates and populates the LSData object from an xlsm LS output file. + + Parameters + ---------- + input_xlsm_filename : str + Filename and path of the xlsm file written by LifeScale. + verbose : bool, optional (default = `True`) + If `True`, status messages are written to the command line. + + Returns + ------- + :py:obj:`.LSData` + Contains all LS output data loaded from the given xlsm file. + """ + + REQUIRED_XLSM_SHEET_NAMES = [ # Raise an exception if they are not present in the input xlsm file. + 'AcquisitionIntervals', + 'IntervalAnalysis', + 'PanelData', + 'Settings', + ] + + # Check input data: + # input_xlsm_filename: + if not input_xlsm_filename: + raise AssertionError(f'The parameter "input_xlsm_filename" must not be empty!') + if not isinstance(input_xlsm_filename, str): + raise TypeError('"input_xlsm_filename" needs to be a string') + if not os.path.isfile(input_xlsm_filename): + raise AssertionError(f'XLSM file {input_xlsm_filename} does not exist!') + + # Load all needed sheets of the xlsm file to pandas dataframes: + if verbose: + print(f'Load data from xlsm file: {input_xlsm_filename}') + + # Get sheet names: + xl_file = pd.ExcelFile(input_xlsm_filename) + sheet_names = xl_file.sheet_names + + # Check, if all required sheets are available: + if set(REQUIRED_XLSM_SHEET_NAMES) - set(sheet_names): + missing_sheets = list(set(REQUIRED_XLSM_SHEET_NAMES) - set(sheet_names)) + raise AssertionError(f'The following sheets are missing the file {input_xlsm_filename}: {missing_sheets}') + + # PanelData: + if verbose: + print(f' - Parse PanelData') + df_panel_data = xl_file.parse('PanelData') + df_panel_data = remove_space_from_column_names(df_panel_data) + df_panel_data['NumberOfIntervals'] = None + if not (df_panel_data[['Id']].value_counts().count() == len(df_panel_data)): + raise AssertionError( + f'The values in the column "Id" in PanelData is not unique!') + + # IntervalAnalysis: + if verbose: + print(f' - Parse IntervalAnalysis') + df_interval_analysis = xl_file.parse('IntervalAnalysis') + df_interval_analysis = remove_space_from_column_names(df_interval_analysis) + df_interval_analysis['Status'] = None # From AcquisitionIntervals + df_interval_analysis['DetectedParticles'] = None # From AcquisitionIntervals + df_interval_analysis['MeasuredVolume'] = None # From AcquisitionIntervals + df_interval_analysis['ResonantFrequency'] = None # From AcquisitionIntervals + if not (df_interval_analysis[['Id', 'IntervalNumber']].value_counts().count() == len(df_interval_analysis)): + raise AssertionError( + f'The combination if the values in the columns "Id" and "IntervalNumber" in IntervalAnalysis is not ' + f'unique!') + + # Settings: + if verbose: + print(f' - Parse Settings') + settings_dict = {} + df_tmp = xl_file.parse('Settings', header=None) + for idx, row in df_tmp.iterrows(): + short_row = row[1:] + item_not_nan_max_idx = short_row.loc[~short_row.isna()].index.max() + if item_not_nan_max_idx is np.nan: # No items that are not NaN! + settings_dict[row[0]] = None + else: + tmp_list = short_row.loc[:item_not_nan_max_idx].to_list() + num_items = len(tmp_list) + if num_items == 1: + settings_dict[row[0]] = tmp_list[0] + else: + settings_dict[row[0]] = tmp_list + run_name = settings_dict['Name'] + if settings_dict['Guid'] is None: + guid = '' + else: + guid = str(settings_dict['Guid']) + start_time_dt = settings_dict['StartTime'] + end_time_dt = start_time_dt + dt.timedelta(settings_dict['ElapsedTime'] / (24 * 60)) + + # # Settings (dataframe): + # df_settings = xl_file.parse('Settings', header=None).transpose() + # df_settings.columns = df_settings.iloc[0] + # df_settings = df_settings[1:] + # df_settings.reset_index(drop=True, inplace=True) + + # Masses (from sheet AcquisitionIntervals): + if verbose: + print(f' - Parse Masses') + id_list = [] + well_list = [] + interval_num_list = [] + time_list = [] + masses_list = [] + volume_list = [] + total_num_particles_list = [] + transit_time_list = [] + pressure_drop_list = [] + time_list_length_old = 0 + # masses_list_length_old = 0 + # volume_list_length_old = 0 + # total_num_particles_list_length_old = 0 + # transit_time_list_length_old = 0 + # pressure_drop_list_length_old = 0 + + current_id = None + current_well = None + current_interval_num = None + current_detected_particles = None + + df_tmp = xl_file.parse('AcquisitionIntervals', header=None) + for ids, row in df_tmp.iterrows(): + if row[0] == 'Id': + current_id = row[1] + if ~(df_interval_analysis['Id'] == current_id).any(): + raise AssertionError(f'"ID="{current_id} is not available in IntervalAnalysis!') + if ~(df_panel_data['Id'] == current_id).any(): + raise AssertionError(f'"ID="{current_id} is not available in PanelData!') + continue + if row[0] == 'Well': + current_well = row[1] + if ~(df_interval_analysis['Well'] == current_well).any(): + raise AssertionError(f'"Well="{current_well} is not available in IntervalAnalysis!') + if ~(df_panel_data['Well'] == current_well).any(): + raise AssertionError(f'"Well="{current_well} is not available in PanelData!') + continue + if row[0] == 'Antibiotic': + continue + if row[0] == 'AntibioticConcentration': + continue + if row[0] == 'NumberOfIntervals': + df_panel_data.loc[df_panel_data['Id'] == current_id, 'NumberOfIntervals'] = row[1] + continue + if row[0] == 'IntervalNumber': + current_interval_num = row[1] + if ~(df_interval_analysis['IntervalNumber'] == current_interval_num).any(): + raise AssertionError( + f'"IntervalNumber="{current_interval_num} is not available in IntervalAnalysis!') + continue + if row[0] == 'StartTime': + continue + if row[0] == 'EndTime': + continue + if row[0] == 'DilutionFactor': + continue + if row[0] == 'Status': + tmp_filter = (df_interval_analysis['Id'] == current_id) & ( + df_interval_analysis['IntervalNumber'] == current_interval_num) + if len(tmp_filter[tmp_filter]) != 1: + raise AssertionError( + f'Invalid number of matches of "Id={current_id}" and "IntervalNumber={current_interval_num}" ' + f'in IntervalAnalysis: {len(tmp_filter[tmp_filter])}') + df_interval_analysis.loc[tmp_filter, 'Status'] = row[1] + continue + if row[0] == 'DetectedParticles': + tmp_filter = (df_interval_analysis['Id'] == current_id) & ( + df_interval_analysis['IntervalNumber'] == current_interval_num) + if len(tmp_filter[tmp_filter]) != 1: + raise AssertionError( + f'Invalid number of matches of "Id={current_id}" and "IntervalNumber={current_interval_num}" ' + f'in IntervalAnalysis: {len(tmp_filter[tmp_filter])}') + df_interval_analysis.loc[tmp_filter, 'DetectedParticles'] = row[1] + current_detected_particles = row[1] # For cross-checks + continue + if row[0] == 'MeasuredVolume': + tmp_filter = (df_interval_analysis['Id'] == current_id) & ( + df_interval_analysis['IntervalNumber'] == current_interval_num) + if len(tmp_filter[tmp_filter]) != 1: + raise AssertionError( + f'Invalid number of matches of "Id={current_id}" and "IntervalNumber={current_interval_num}" ' + f'in IntervalAnalysis: {len(tmp_filter[tmp_filter])}') + df_interval_analysis.loc[tmp_filter, 'MeasuredVolume'] = row[1] + continue + if row[0] == 'ResonantFrequency': + tmp_filter = (df_interval_analysis['Id'] == current_id) & ( + df_interval_analysis['IntervalNumber'] == current_interval_num) + if len(tmp_filter[tmp_filter]) != 1: + raise AssertionError( + f'Invalid number of matches of "Id={current_id}" and "IntervalNumber={current_interval_num}" ' + f'in IntervalAnalysis: {len(tmp_filter[tmp_filter])}') + df_interval_analysis.loc[tmp_filter, 'ResonantFrequency'] = row[1] + continue + if row[0] == 'Time': + tmp_list = row_to_list(row[1:]) + if (len(tmp_list) == 0) and (current_detected_particles != 0): + raise AssertionError( + f'Number of "DetectedParticles={current_detected_particles}" does not match length of "Time" ' + f'series (= {len(tmp_list)}) ') + time_list += tmp_list + continue + if row[0] == 'Mass': + tmp_list = row_to_list(row[1:]) + masses_list += tmp_list + continue + if row[0] == 'Volume': + tmp_list = row_to_list(row[1:]) + volume_list += tmp_list + continue + if row[0] == 'TotalNumberOfParticlesThroughSensor': + tmp_list = row_to_list(row[1:]) + total_num_particles_list += tmp_list + continue + if row[0] == 'TransitTime': + tmp_list = row_to_list(row[1:]) + transit_time_list += tmp_list + continue + if row[0] == 'PressureDrop': + tmp_list = row_to_list(row[1:]) + pressure_drop_list += tmp_list + + # Finish data collection for the current Interval (sample): + # Check if the length of all data series of the current interval number match: + if not (len(time_list) == len(masses_list) == len(volume_list) == len(total_num_particles_list) == len( + transit_time_list) == len(pressure_drop_list)): + raise AssertionError( + f'The lengths of the data series in AcquisitionIntervals of "Well={current_well}" and ' + f'"IntervalNumber={current_interval_num}" do not match!') + # Set up lists for well, id and interval number: + num_additional_items_in_data_series = len(time_list) - time_list_length_old + tmp_list = [current_id] * num_additional_items_in_data_series + id_list += tmp_list + tmp_list = [current_well] * num_additional_items_in_data_series + well_list += tmp_list + tmp_list = [current_interval_num] * num_additional_items_in_data_series + interval_num_list += tmp_list + # Reset counters: + time_list_length_old = len(time_list) + # masses_list_length_old = len(masses_list) + # volume_list_length_old = len(volume_list) + # total_num_particles_list_length_old = len(total_num_particles_list) + # transit_time_list_length_old = len(transit_time_list) + # pressure_drop_list_length_old = len(pressure_drop_list) + continue + + # Check if the length of all data series lists match: + if not (len(time_list) == len(masses_list) == len(volume_list) == len(total_num_particles_list) == len( + transit_time_list) == len(pressure_drop_list) == len(id_list) == len(well_list) == len( + interval_num_list)): + raise AssertionError( + f'The lengths of the data series in AcquisitionIntervals do not match!') + + # Create dataframe: + df_masses_columns = ['Id', 'Well', 'IntervalNumber', 'Time', 'Mass', 'Volume', + 'TotalNumberOfParticlesThroughSensor', 'TransitTime', 'PressureDrop'] + df_masses = pd.DataFrame(list( + zip(id_list, well_list, interval_num_list, time_list, masses_list, volume_list, total_num_particles_list, + transit_time_list, pressure_drop_list)), + columns=df_masses_columns) + df_masses['Id'] = df_masses['Id'].astype(int) + df_masses['IntervalNumber'] = df_masses['IntervalNumber'].astype(int) + + # Sensor: + # sensor_dict = {} + # df_sensor = xl_file.parse('Sensor', header=None).transpose() + # # - The df needs to have two rows => key and value for the dict! + # if df_sensor.shape[0] != 2: + # raise AssertionError(f'More than one column of parameters in the sheet "Sensor!"') + # for col_idx in df_sensor.columns: + # sensor_dict[df_sensor.loc[0, col_idx]] = df_sensor.loc[1, col_idx] + + if verbose: + print(f'...finished loading and parsing data!') + + return cls(run_name=run_name, + guid=guid, + input_xlsm_filename=input_xlsm_filename, + start_time_dt=start_time_dt, + end_time_dt=end_time_dt, + settings_dict=settings_dict, + df_panel_data=df_panel_data, + df_interval_analysis=df_interval_analysis, + df_masses=df_masses, + ) + + def export_csv_files(self, output_filepath, sort_by_time=False, verbose=True): + """Write CSV files to output directory + + Parameters + ---------- + output_filepath : str + Path to the output directory. + sort_by_time : bool, optional (default=`False`) + Sort data in the Masses CSV file by the observation time. + verbose : bool, optional (default = `True`) + If `True`, status messages are written to the command line. + + Returns + ------- + :py:obj:`.LSData` + Contains all LS output data loaded from the given xlsm file. + + """ + if verbose: + print('Write output') + + # Checks: + if not os.path.exists(output_filepath): + raise AssertionError(f'The output path does not exist: {output_filepath}') + self.output_dir_path = output_filepath + + if self.guid: + filename_ending = f'{self.run_name}_{self.guid}.csv' + else: + filename_ending = f'{self.run_name}.csv' + + # Write PanelData: + filename = os.path.join(output_filepath, f'Metadata_{filename_ending}') + if verbose: + print(f'Write PanelData to: {filename}') + self.df_panel_data.to_csv(filename, index=False) + + # Write Masses: + + filename = os.path.join(output_filepath, f'Masses_{filename_ending}') + if verbose: + print(f'Write Masses to: {filename}') + if sort_by_time: + if verbose: + print(f' - Sort Masses by Time.') + self.df_masses.sort_values(by=['Time']).to_csv(filename, index=False) + else: + self.df_masses.to_csv(filename, index=False) + + # Write IntervalAnalysis: + filename = os.path.join(output_filepath, f'SamplesSummary_{filename_ending}') + if verbose: + print(f'Write IntervalAnalysis to: {filename}') + self.df_interval_analysis.to_csv(filename, index=False) + + # TODO: Output format (number of digits) + # TODO: Select columns for output (settable as parameter + default settings for each csv file) + + def calc_sample_statistics(self, verbose=True): + """Calculate statistical values for each sample and add it to the self.df_interval_analysis.""" + if verbose: + print('Calculate sample statistics.') + + for idx, row in self.df_interval_analysis.iterrows(): + tmp_filter = (self.df_masses['Id'] == row.Id) & (self.df_masses['IntervalNumber'] == row.IntervalNumber) + tmp_filter_1 = (self.df_interval_analysis['Id'] == row.Id) & \ + (self.df_interval_analysis['IntervalNumber'] == row.IntervalNumber) + self.df_interval_analysis.loc[tmp_filter_1, 'Mass_median'] = self.df_masses.loc[tmp_filter, 'Mass'].median() + self.df_interval_analysis.loc[tmp_filter_1, 'Mass_std'] = self.df_masses.loc[tmp_filter, 'Mass'].std() + self.df_interval_analysis.loc[tmp_filter_1, 'Mass_q25'] = \ + self.df_masses.loc[tmp_filter, 'Mass'].quantile(0.25) + self.df_interval_analysis.loc[tmp_filter_1, 'Mass_q75'] = \ + self.df_masses.loc[tmp_filter, 'Mass'].quantile(0.75) + self.df_interval_analysis.loc[tmp_filter_1, 'Mass_iqr'] = \ + self.df_interval_analysis.loc[tmp_filter_1, 'Mass_q75'] - \ + self.df_interval_analysis.loc[tmp_filter_1, 'Mass_q25'] + + @property + def get_number_of_observations(self): + """Return the number of observations (items in the data series).""" + if self.df_masses is not None: + return len(self.df_masses) + else: + return 0 + + @property + def get_number_of_wells(self): + """Return the number wells.""" + if self.df_panel_data is not None: + return len(self.df_panel_data) + else: + return 0 + + @property + def get_number_of_intervals(self): + """Return the number intervals.""" + if self.df_interval_analysis is not None: + return len(self.df_interval_analysis) + else: + return 0 + + def __str__(self): + if self.run_name is not None: + return f'Run "{self.run_name}" with {self.get_number_of_observations} observations in ' \ + f'{self.get_number_of_intervals} intervals and {self.get_number_of_wells} wells. ' + else: + return f'Not data available yet.' + + +def remove_space_from_column_names(df): + """Removes white space from column names of input dataframe.""" + col_names = df.columns + col_names_corrected = [] + for col_name in col_names: + col_names_corrected.append(col_name.strip()) + df.columns = col_names_corrected + return df + + +def row_to_list(row) -> list: + """Convert dataframe row to list and remove all trailing NaN values.""" + item_not_nan_max_idx = row.loc[~row.isna()].index.max() + if item_not_nan_max_idx is np.nan: # No items that are not NaN! + out_list = [] + else: + out_list = row.loc[:item_not_nan_max_idx].to_list() + return out_list + + +if __name__ == '__main__': + """Main function, primarily for debugging and testing.""" + xlsm_filename = '../../data/Example_several_wells/Vibrio_starvation_24.11.22_221125_163425.xlsm' + output_directory = '../../output/' + + ls_data = LSData.from_xlsm_file(input_xlsm_filename=xlsm_filename) + ls_data.export_csv_files(output_directory) + + print('End') diff --git a/lifescale/models/ls_run.py b/lifescale/models/ls_run.py new file mode 100644 index 0000000000000000000000000000000000000000..a90fbe35cdc8241531aee01d12d230388a25db0f --- /dev/null +++ b/lifescale/models/ls_run.py @@ -0,0 +1,61 @@ +"""Modelling a LifeScale run + +Copyright (C) 2022 Andreas Hellerschmied <heller182@gmx.at> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +""" + +import datetime as dt +import pytz +import numpy as np +import pickle +import os +import sys + +import pandas as pd + +# from gravtools.models.lsm import LSM + + +class LSRun: + """LifeScale run object. + + A LS run contains: + + + - run name and description + - input data + - settings + + Attributes + ---------- + run_name : str + Name of the lsm run. + output_directory : str + Path to output directory (all output files are stored there). + pgm_version : str + Version of the program. + """ + + def __init__(self, + campaign_name, + output_directory, + surveys=None, # Always use non-mutable default arguments! + stations=None, # Always use non-mutable default arguments! + lsm_runs=None, # Always use non-mutable default arguments! + ref_delta_t_dt=None # Reference time for drift determination + ): + """ + Parameters + """ \ No newline at end of file diff --git a/lifescale/scripts/__init__.py b/lifescale/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5ba464a9704a1acaa495ff9aa486b11588fe1007 --- /dev/null +++ b/lifescale/scripts/__init__.py @@ -0,0 +1,18 @@ +"""LifeScale utils scripts module. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. + +:Authors: + Andreas Hellerschmied (heller182@gmx.at) +""" \ No newline at end of file diff --git a/lifescale/scripts/ls2csv.py b/lifescale/scripts/ls2csv.py new file mode 100644 index 0000000000000000000000000000000000000000..55c902b816a4ff2e01e64c08e5655b964a6aa250 --- /dev/null +++ b/lifescale/scripts/ls2csv.py @@ -0,0 +1,27 @@ +"""Conversion program from xlsm to csv. + +Copyright (C) 2022 Andreas Hellerschmied <heller182@gmx.at> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +""" + +from lifescale.models.ls_data import LSData + + +def ls2csv(xlsm_filename, output_dir, sample_stats=True, sort_by_time=False, verbose=True): + """Convert lifescale output file (xlsm) to csv files.""" + ls_data = LSData.from_xlsm_file(input_xlsm_filename=xlsm_filename, verbose=verbose) + if sample_stats: + ls_data.calc_sample_statistics(verbose=verbose) + ls_data.export_csv_files(output_dir, sort_by_time=sort_by_time, verbose=verbose) diff --git a/lifescale/scripts/run_gui.py b/lifescale/scripts/run_gui.py new file mode 100644 index 0000000000000000000000000000000000000000..c7b7fb62509c887fc4a315e287d932242937777f --- /dev/null +++ b/lifescale/scripts/run_gui.py @@ -0,0 +1,15 @@ +"""Start the lifescale GUI from here! + +Copyright (C) 2022 Andreas Hellerschmied <heller182@gmx.at> +""" +from lifescale.gui.gui_main import main + + +def run_gui(): + """Start the GUI.""" + main() + + +if __name__ == "__main__": + """Main Program.""" + run_gui() diff --git a/lifescale_gui/gui/__init__.py b/lifescale_gui/gui/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/lifescale_gui/models/__init__.py b/lifescale_gui/models/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/lifescale_gui/naive_peaks/__init__.py b/lifescale_gui/naive_peaks/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/makefile b/makefile index df442737e8a54169f99fa88bac257d42fd159a0e..50604b59aac78b45ba9a97620a3214e50df003c5 100644 --- a/makefile +++ b/makefile @@ -5,10 +5,27 @@ test: $(info Test-run for this makefile!) $(info Yeah!!) -# Project initaialization +# Project initialization init: pip install -r requirements.txt # Convert *.ui files from Qt Designer to Python files: py_gui: - pyuic5 -o lifescale_gui/gui/MainWindow.py lifescale_gui/gui/MainWindow.ui + pyuic6 -o lifescale/gui/MainWindow.py lifescale/gui/MainWindow.ui + +# Package test (install in current virtual environment, editable install with pip) +test_pack: + pip install -e . + +# Uninstall test package +test_pack_uninstall: + pip uninstall gravtools + +# Build package with setuptools (new version): +build: + rm -rf lifescale_utils.egg-info/ + python -m build + +# Upload package to pypi.org +pypi_push: + twine upload --verbose dist/* \ No newline at end of file diff --git a/output/.gitignore b/output/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c96a04f008ee21e260b28f7701595ed59e2839e3 --- /dev/null +++ b/output/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..07de284aa5c45f56b69ca6f605edf72a14785b99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000000000000000000000000000000000..5cb308073936bf00b7166d0d1f735ff6878304e2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,35 @@ +[metadata] +name = lifescale-utils +version = attr: lifescale.__version__ +author = Andreas Hellerschmied +author_email = heller182@gmx.at +url = https://gitlab.com/hellerdev/lifescale_utils +description = Lifescale utility software. +long_description = file: README.md +long_description_content_type = text/markdown +keywords = Lifescale +license = GNU GPLv3 +license_files = LICENSE +classifiers = + License :: OSI Approved :: GNU General Public License (GPL) + Programming Language :: Python :: 3 + +[options] +python_requires = >=3.6, <4 +packages = find: +zip_safe = True +include_package_data = True +install_requires = + numpy + pandas + openpyxl + +[options.entry_points] +console_scripts = + ls2csv = lifescale.command_line.command_line:ls2csv + +[options.packages.find] +exclude = + lifescale.gui* + lifescale.scripts.run_gui.py + lifescale.models.ls_run.py