From 0b60e8f1e67acef0aac7c4b5298a2f251eb90d52 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Fri, 17 May 2024 15:21:22 -0400 Subject: [PATCH 01/17] adding gui code [skip ci] --- lusSTR/cli/__init__.py | 31 ++- lusSTR/cli/gui.py | 558 +++++++++++++++++++++++++++++++++++++++++ lusSTR/cli/logo.png | Bin 0 -> 49751 bytes setup.py | 2 + 4 files changed, 582 insertions(+), 9 deletions(-) create mode 100644 lusSTR/cli/gui.py create mode 100644 lusSTR/cli/logo.png diff --git a/lusSTR/cli/__init__.py b/lusSTR/cli/__init__.py index e6fae75d..1a9aae7e 100644 --- a/lusSTR/cli/__init__.py +++ b/lusSTR/cli/__init__.py @@ -1,33 +1,43 @@ import argparse +import os +import subprocess import lusSTR from lusSTR.cli import config from lusSTR.cli import strs from lusSTR.cli import snps +from lusSTR.cli import gui import snakemake - mains = { "config": config.main, "strs": strs.main, - "snps": snps.main + "snps": snps.main, + "gui": gui.main } subparser_funcs = { "config": config.subparser, "strs": strs.subparser, - "snps": snps.subparser + "snps": snps.subparser, + "gui": gui.subparser } - def main(args=None): - if args is None: + if args is None: args = get_parser().parse_args() if args.subcmd is None: get_parser().parse_args(["-h"]) - mainmethod = mains[args.subcmd] - result = mainmethod(args) - return result - + elif args.subcmd == "gui": + # Get the directory containing the script (cli folder) + script_dir = os.path.dirname(os.path.realpath(__file__)) + # Construct the path to gui.py relative to the script directory + gui_path = os.path.join(script_dir, "gui.py") + # Call streamlit run command + subprocess.run(["streamlit", "run", gui_path]) + else: + mainmethod = mains[args.subcmd] + result = mainmethod(args) + return result def get_parser(): parser = argparse.ArgumentParser() @@ -39,3 +49,6 @@ def get_parser(): for func in subparser_funcs.values(): func(subparsers) return parser + +if __name__ == "__main__": + main() diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py new file mode 100644 index 00000000..b2529bf0 --- /dev/null +++ b/lusSTR/cli/gui.py @@ -0,0 +1,558 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (c) 2024, DHS. +# +# This file is part of lusSTR (http://github.com/bioforensics/lusSTR) and is licensed under +# the BSD license: see LICENSE.txt. +# +# This software was prepared for the Department of Homeland Security (DHS) by the Battelle National +# Biodefense Institute, LLC (BNBI) as part of contract HSHQDC-15-C-00064 to manage and operate the +# National Biodefense Analysis and Countermeasures Center (NBACC), a Federally Funded Research and +# Development Center. +# ------------------------------------------------------------------------------------------------- +################################################################# +# Importing Necessary Packages # +################################################################# + +import streamlit as st +from streamlit_option_menu import option_menu +import yaml +import subprocess +import os +import re + +# ------ Packages For File/Folder Directory Selection --------- # + +import tkinter as tk +from tkinter import filedialog + +# Create a global Tkinter root window +root = tk.Tk() +root.withdraw() # Hide the root window + +################################################################# +# Functions # +################################################################# + +# ------------ Function to Generate config.yaml File ---------- # + +def generate_config_file(config_data, working_directory, workflow_type): + if workflow_type == "STR": + config_filename = 'config.yaml' + elif workflow_type == "SNP": + config_filename = 'snp_config.yaml' + else: + raise ValueError("Invalid workflow type. Please specify either 'STR' or 'SNP'.") + + config_path = os.path.join(working_directory, config_filename) + with open(config_path, 'w') as file: + yaml.dump(config_data, file) + +# ------------ Function for folder selection ------------------ # + +def folder_picker_dialog(): + folder_path = filedialog.askdirectory(master=root) + return folder_path + +# ------- Function for individual file selection -------------- # + +def file_picker_dialog(): + file_path = filedialog.askopenfilename(master=root) + return file_path + +# ---- Function to validate prefix for output folder ---------- # + +def validate_prefix(prefix): + if re.match(r'^[A-Za-z0-9_-]+$', prefix): # Allow alphanumeric characters, underscore, and hyphen + return True + else: + return False + +################################################################# +# Front-End Logic For Navigation Bar # +################################################################# + +def main(): + + # Page Layout (Theme and Fonts have been established in .streamlit/config.toml) + st.set_page_config(layout='wide', initial_sidebar_state='collapsed') + + # Creating Navigation Bar + + selected = option_menu( + menu_title=None, + options=["Home", "STR", "SNP", "How to Use", "Contact"], + icons=["house", "gear", "gear-fill", "book", "envelope"], + menu_icon="cast", + default_index=0, + orientation="horizontal" + ) + + if selected == "Home": + show_home_page() + + elif selected == "STR": + show_STR_page() + + elif selected == "SNP": + show_SNP_page() + + elif selected == "How to Use": + show_how_to_use_page() + + elif selected == "Contact": + show_contact_page() + +##################################################################### +# lusSTR Home Page # +##################################################################### + +def show_home_page(): + + image_path = "logo.png" + + # CSS to hide full-screen button + hide_img_fs = ''' + + ''' + + # Define column layout for centering image + left_co, cent_co, last_co = st.columns([2.5, 8, 2.5]) + with cent_co: + st.image(image_path, use_column_width="auto") + + # Apply CSS to hide full-screen button + st.markdown(hide_img_fs, unsafe_allow_html=True) + +# -- Welcome Message Stuff + + st.markdown(""" + lusSTR is a tool written in Python to convert Next Generation Sequencing (NGS) data of forensic STR loci to different sequence representations (sequence bracketed form) and allele designations (CE allele, LUS/LUS+ alleles) for ease in downstream analyses. + For more information on LusSTR, visit our [GitHub page](https://github.com/bioforensics/lusSTR/tree/master). + """, unsafe_allow_html=True) + + st.info('Please Select One of the Tabs Above to Get Started on Processing Your Data!') + +##################################################################### +# STR WORKFLOW # +##################################################################### + +##################################################################### +# Specify STR Settings Which Will Be Used to Generate Config File # +##################################################################### + +def show_STR_page(): + + st.title("STR Workflow") + st.info('Please Select STR Settings Below for LusSTR! For Information Regarding the Settings, See the How to Use Tab.') + + # Input File Specification + st.subheader("Input Files Selection") + + # Ask user if submitting a directory or individual file + st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") + input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) + + # Initialize session state if not already initialized + if 'samp_input' not in st.session_state: + st.session_state.samp_input = None + + # Logic for Path Picker based on user's input option + + if input_option == "Directory with Multiple Files": + st.write('Please select a folder:') + clicked = st.button('Folder Picker') + if clicked: + dirname = folder_picker_dialog() + #st.text_input('You Selected The Following folder:', dirname) + st.session_state.samp_input = dirname + + else: + st.write('Please select a file:') + clicked_file = st.button('File Picker') + if clicked_file: + filename = file_picker_dialog() + #st.text_input('You Selected The Following file:', filename) + st.session_state.samp_input = filename + + # Display The Selected Path + if st.session_state.samp_input: + st.text_input("Location Of Your Input File(s):", st.session_state.samp_input) + + # Store the Selected Path to Reference in Config + samp_input = st.session_state.samp_input + +##################################################################### +# STR: General Software Settings to Generate Config File # +##################################################################### + + st.subheader("General Software") + + analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor', 'GeneMarker HTS': 'genemarker'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3", "GeneMarker HTS"], help="Indicate the analysis software used prior to lusSTR sex.")] + + sex = st.checkbox("Include sex-chromosome STRs", help = "Check the box if yes, otherwise leave unchecked.") + + output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") + +##################################################################### +# STR: Convert Settings to Generate Config File # +##################################################################### + + st.subheader("Convert Settings") + + kit = {'ForenSeq Signature Prep': 'forenseq', 'PowerSeq 46GY': 'powerseq'}[st.selectbox("Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"])] + + nocombine = st.checkbox("Do Not Combine Identical Sequences") + +##################################################################### +# STP: Filter Settings to Generate Config File # +##################################################################### + + st.subheader("Filter Settings") + + output_type = {'STRmix': 'strmix', 'EuroForMix': 'efm', 'MPSproto': 'mpsproto'}[st.selectbox("Output Type", options=["STRmix", "EuroForMix", "MPSproto"])] + + profile_type = {'Evidence': 'evidence', 'Reference': 'reference'}[st.selectbox("Profile Type", options=["Evidence", "Reference"])] + + data_type = {'Sequence': 'ngs', 'CE allele': 'ce', 'LUS+ allele': 'lusplus'}[st.selectbox("Data Type", options=["Sequence", "CE allele", "LUS+ allele"])] + + info = st.checkbox("Create Allele Information File") + + separate = st.checkbox("Create Separate Files for Samples", help = "If True, Will Create Individual Files for Samples; If False, Will Create One File with all Samples.") + + nofilters = st.checkbox("Skip all filtering steps", help = "Skip all Filtering Steps; Will Still Create EFM/MPSproto/STRmix Output Files") + + strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicates the Strand Orientation in which to Report the Sequence in the Final Output Table for STRmix NGS only.")] + +##################################################################### +# STR: Specify Working Directory # +##################################################################### + + st.subheader("Set Working Directory") + + # Initialize session state if not already initialized + if 'wd_dirname' not in st.session_state: + st.session_state.wd_dirname = None + + clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') + if clicked_wd: + wd = folder_picker_dialog() + st.session_state.wd_dirname = wd + + # Display selected path + if st.session_state.wd_dirname: + st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) + + # Store Selected Path to Reference in Config + wd_dirname = st.session_state.wd_dirname + +##################################################################### +# STR: Generate Config File Based on Settings # +##################################################################### + + # Submit Button Instance + if st.button("Submit"): + + # Check if all required fields are filled + if analysis_software and samp_input and output and wd_dirname: + + # Validate output prefix + if not validate_prefix(output): + st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") + st.stop() # Stop execution if prefix is invalid + + # Display loading spinner (Continuing Process Checks Above Were Passed) + with st.spinner("Processing Your Data..."): + + # Construct config data + + config_data = { + "analysis_software": analysis_software, + "sex": sex, + "samp_input": samp_input, + "output": output, + "kit": kit, + "nocombine": nocombine, + "output_type": output_type, + "profile_type": profile_type, + "data_type": data_type, + "info": info, + "separate": separate, + "nofilters": nofilters, + "strand": strand + } + + # Generate YAML config file + generate_config_file(config_data, wd_dirname, "STR") + + # Subprocess lusSTR commands + command = ["lusstr", "strs", "all"] + + # Specify WD to lusSTR + if wd_dirname: + command.extend(["-w", wd_dirname + "/"]) + + # Run lusSTR command in terminal + try: + subprocess.run(command, check=True) + st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") + except subprocess.CalledProcessError as e: + st.error(f"Error: {e}") + st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") + + else: + st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") + +##################################################################### +# SNP WORKFLOW # +##################################################################### + +##################################################################### +# Specify SNP Settings Which Will Be Used to Generate Config File # +##################################################################### + +def show_SNP_page(): + + st.title("SNP Workflow") + st.info('Please Select SNP Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab.') + + # Input File Specification + st.subheader("Input Files Selection") + + # Ask user if submitting a directory or individual file + st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") + input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) + + # Initialize session state if not already initialized + if 'samp_input' not in st.session_state: + st.session_state.samp_input = None + + # Logic for Path Picker based on user's input option + + if input_option == "Directory with Multiple Files": + st.write('Please select a folder:') + clicked = st.button('Folder Picker') + if clicked: + dirname = folder_picker_dialog() + #st.text_input('You Selected The Following folder:', dirname) + st.session_state.samp_input = dirname + + else: + st.write('Please select a file:') + clicked_file = st.button('File Picker') + if clicked_file: + filename = file_picker_dialog() + #st.text_input('You Selected The Following file:', filename) + st.session_state.samp_input = filename + + # Display The Selected Path + if st.session_state.samp_input: + st.text_input("Location Of Your Input File(s):", st.session_state.samp_input) + + # Store Selected Path to Reference in Config + samp_input = st.session_state.samp_input + +##################################################################### +# SNP: General Software Settings to Generate Config File # +##################################################################### + + st.subheader("General Software") + + analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3"], help="Indicate the analysis software used prior to lusSTR sex.")] + + output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") + + kit = {'Signature Prep': 'sigprep', 'Kintelligence': 'kintelligence'}[st.selectbox("Library Preparation Kit", options=["Signature Prep", "Kintelligence"])] + +##################################################################### +# SNP: Format Settings to Generate Config File # +##################################################################### + + st.subheader("Format Settings") + + # -- Select Type (Unique to SNP Workflow) + types_mapping = {"Identify SNPs Only": "i", "Phenotype Only": "p", "Ancestry Only": "a", "All": "all"} + selected_types = st.multiselect("Select Types:", options=types_mapping.keys(), help="Please Select a Choice or any Combination") + types_string = "all" if "All" in selected_types else ", ".join(types_mapping.get(t, t) for t in selected_types) + + #if selected_types: + # st.text_input("You Selected:", types_string) + + # -- Filter + nofilters = st.checkbox("Skip all filtering steps", help = "If no filtering is desired at the format step; if False, will remove any allele designated as Not Typed") + +##################################################################### +# SNP: Convert Settings to Generate Config File # +##################################################################### + + st.subheader("Convert Settings") + + separate = st.checkbox("Create Separate Files for Samples", help = "If want to separate samples into individual files for use in EFM") + + strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicate which orientation to report the alleles for the SigPrep SNPs.")] + + # Analytical threshold value + thresh = st.number_input("Analytical threshold value:", value=0.03, step=0.01, min_value = 0.0) + +##################################################################### +# SNP: Specify a Reference File if User Has One # +##################################################################### + + st.subheader("Specify a Reference File (Optional)") + + if 'reference' not in st.session_state: + st.session_state.reference = None + + clicked_ref = st.button('Please Specify Your Reference File If You Have One', help = "List IDs of the samples to be run as references in EFM; default is no reference samples") + if clicked_ref: + ref = file_picker_dialog() + st.session_state.reference = ref + + # Display Path to Selected Reference File + if st.session_state.reference: + st.text_input("Your Specified Reference File:", st.session_state.reference) + + # Store Selected Path to Reference in Config + reference = st.session_state.reference + +##################################################################### +# SNP: Specify Working Directory # +##################################################################### + + st.subheader("Set Working Directory") + + # Initialize session state if not already initialized + if 'wd_dirname' not in st.session_state: + st.session_state.wd_dirname = None + + clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') + if clicked_wd: + wd = folder_picker_dialog() + st.session_state.wd_dirname = wd + + # Display selected path + if st.session_state.wd_dirname: + st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) + + # Store Selected Path to Reference in Config + wd_dirname = st.session_state.wd_dirname + +##################################################################### +# SNP: Generate Config File Based on Settings # +##################################################################### + + # Submit Button Instance + if st.button("Submit"): + + # Check if all required fields are filled + if analysis_software and samp_input and output and wd_dirname: + + # Validate output prefix + if not validate_prefix(output): + st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") + st.stop() # Stop execution if prefix is invalid + + # Display loading spinner (Continuing Process Checks Above Were Passed) + with st.spinner("Processing Your Data..."): + + # Construct config data + + config_data = { + "analysis_software": analysis_software, + "samp_input": samp_input, + "output": output, + "kit": kit, + "types": types_string, + "thresh": thresh, + "separate": separate, + "nofilter": nofilters, + "strand": strand, + "references": None # Default value is None + } + + # If a reference file was specified, add to config + if reference: + config_data["references"] = reference + + # Generate YAML config file + generate_config_file(config_data, wd_dirname, "SNP") + + # Subprocess lusSTR commands + command = ["lusstr", "snps", "all"] + + # Specify WD to lusSTR + if wd_dirname: + command.extend(["-w", wd_dirname + "/"]) + + # Run lusSTR command in terminal + try: + subprocess.run(command, check=True) + st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") + except subprocess.CalledProcessError as e: + st.error(f"Error: {e}") + st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") + + else: + st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") + +##################################################################### +# How To Use Page # +##################################################################### + +def show_how_to_use_page(): + + st.title("Common Errors and Best Practices for Using lusSTR") + + st.header("1. File/Folder Path Formatting") + + st.write("Please ensure that the displayed path accurately reflects your selection. When using the file or folder picker, navigate to the desired location and click 'OK' to confirm your selection.") + + st.header("2. Specifying Output Prefix") + + st.write("The purpose of specifying the output prefix is for lusSTR to create result files and folders with that prefix in your working directory. Please ensure that you are following proper file naming formatting and rules when specifying this prefix. Avoid using characters such as '/', '', '.', and others. Note: To avoid potential errors, you can simply use the default placeholder for output.") + + st.code("Incorrect: 'working_directory/subfolder/subfolder'\nCorrect: working_directory/output # or just output, since you will likely already be in the working directory when specifying it before submitting.") + + st.write("Note that some result files may be saved directly in the working directory with the specified prefix, while others will be populated in a folder labeled with the prefix in your working directory.") + st.write("Be aware of this behavior when checking for output files.") + + st.header("3. Specifying Working Directory") + st.write("Please Ensure That You Properly Specify a Working Directory. This is where all lusSTR output files will be saved. To avoid potential errors, specifying a working directory is required.") + + st.title("About lusSTR") + + st.markdown(""" + + **_lusSTR Accommodates Four Different Input Formats:_** + + (1) UAS Sample Details Report, UAS Sample Report, and UAS Phenotype Report (for SNP processing) in .xlsx format (a single file or directory containing multiple files) + + (2) STRait Razor v3 output with one sample per file (a single file or directory containing multiple files) + + (3) GeneMarker v2.6 output (a single file or directory containing multiple files) + + (4) Sample(s) sequences in CSV format; first four columns must be Locus, NumReads, Sequence, SampleID; Optional last two columns can be Project and Analysis IDs. + + + """, unsafe_allow_html = True) + +##################################################################### +# Contact Page # +##################################################################### + +def show_contact_page(): + st.title("Contact Us") + st.write("For any questions or issues, please contact rebecca.mitchell@st.dhs.gov, daniel.standage@st.dhs.gov, or s.h.syed@email.msmary.edu") + +##################################################################### +# lusSTR RUN # +##################################################################### + +if __name__ == "__main__": + main() + +def subparser(subparsers): + parser = subparsers.add_parser("gui", help="Launch the Streamlit GUI") + parser.set_defaults(func=main) diff --git a/lusSTR/cli/logo.png b/lusSTR/cli/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..23e2171a609dc45a76e9264b2caad96038b5967b GIT binary patch literal 49751 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�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.22,<8.0", "pyyaml>=6.0", "matplotlib>=3.8", + "streamlit>=1.31.0", + "streamlit_option_menu>=0.3.12", ], entry_points={"console_scripts": ["lusstr = lusSTR.cli:main"]}, scripts=glob.glob("lusSTR/scripts/*"), From 5e483cb982a2473cacacd3f65e2f212cc7e9e300 Mon Sep 17 00:00:00 2001 From: Daniel Standage Date: Fri, 24 May 2024 08:53:55 -0400 Subject: [PATCH 02/17] Fixing streamlit call --- lusSTR/cli/__init__.py | 17 +- lusSTR/cli/gui.py | 1117 ++++++++++++++++++++-------------------- 2 files changed, 566 insertions(+), 568 deletions(-) diff --git a/lusSTR/cli/__init__.py b/lusSTR/cli/__init__.py index 1a9aae7e..e3a20d5b 100644 --- a/lusSTR/cli/__init__.py +++ b/lusSTR/cli/__init__.py @@ -1,12 +1,12 @@ import argparse -import os -import subprocess +import importlib.resources +import streamlit.web.cli as stcli +import sys import lusSTR from lusSTR.cli import config from lusSTR.cli import strs from lusSTR.cli import snps from lusSTR.cli import gui -import snakemake mains = { "config": config.main, @@ -27,13 +27,10 @@ def main(args=None): args = get_parser().parse_args() if args.subcmd is None: get_parser().parse_args(["-h"]) - elif args.subcmd == "gui": - # Get the directory containing the script (cli folder) - script_dir = os.path.dirname(os.path.realpath(__file__)) - # Construct the path to gui.py relative to the script directory - gui_path = os.path.join(script_dir, "gui.py") - # Call streamlit run command - subprocess.run(["streamlit", "run", gui_path]) + elif args.subcmd == "gui": + gui_path = importlib.resources.files("lusSTR") / "cli" / "gui.py" + sys.argv = ["streamlit", "run", str(gui_path)] + sys.exit(stcli.main()) else: mainmethod = mains[args.subcmd] result = mainmethod(args) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index b2529bf0..e8ffc468 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -1,558 +1,559 @@ -# ------------------------------------------------------------------------------------------------- -# Copyright (c) 2024, DHS. -# -# This file is part of lusSTR (http://github.com/bioforensics/lusSTR) and is licensed under -# the BSD license: see LICENSE.txt. -# -# This software was prepared for the Department of Homeland Security (DHS) by the Battelle National -# Biodefense Institute, LLC (BNBI) as part of contract HSHQDC-15-C-00064 to manage and operate the -# National Biodefense Analysis and Countermeasures Center (NBACC), a Federally Funded Research and -# Development Center. -# ------------------------------------------------------------------------------------------------- -################################################################# -# Importing Necessary Packages # -################################################################# - -import streamlit as st -from streamlit_option_menu import option_menu -import yaml -import subprocess -import os -import re - -# ------ Packages For File/Folder Directory Selection --------- # - -import tkinter as tk -from tkinter import filedialog - -# Create a global Tkinter root window -root = tk.Tk() -root.withdraw() # Hide the root window - -################################################################# -# Functions # -################################################################# - -# ------------ Function to Generate config.yaml File ---------- # - -def generate_config_file(config_data, working_directory, workflow_type): - if workflow_type == "STR": - config_filename = 'config.yaml' - elif workflow_type == "SNP": - config_filename = 'snp_config.yaml' - else: - raise ValueError("Invalid workflow type. Please specify either 'STR' or 'SNP'.") - - config_path = os.path.join(working_directory, config_filename) - with open(config_path, 'w') as file: - yaml.dump(config_data, file) - -# ------------ Function for folder selection ------------------ # - -def folder_picker_dialog(): - folder_path = filedialog.askdirectory(master=root) - return folder_path - -# ------- Function for individual file selection -------------- # - -def file_picker_dialog(): - file_path = filedialog.askopenfilename(master=root) - return file_path - -# ---- Function to validate prefix for output folder ---------- # - -def validate_prefix(prefix): - if re.match(r'^[A-Za-z0-9_-]+$', prefix): # Allow alphanumeric characters, underscore, and hyphen - return True - else: - return False - -################################################################# -# Front-End Logic For Navigation Bar # -################################################################# - -def main(): - - # Page Layout (Theme and Fonts have been established in .streamlit/config.toml) - st.set_page_config(layout='wide', initial_sidebar_state='collapsed') - - # Creating Navigation Bar - - selected = option_menu( - menu_title=None, - options=["Home", "STR", "SNP", "How to Use", "Contact"], - icons=["house", "gear", "gear-fill", "book", "envelope"], - menu_icon="cast", - default_index=0, - orientation="horizontal" - ) - - if selected == "Home": - show_home_page() - - elif selected == "STR": - show_STR_page() - - elif selected == "SNP": - show_SNP_page() - - elif selected == "How to Use": - show_how_to_use_page() - - elif selected == "Contact": - show_contact_page() - -##################################################################### -# lusSTR Home Page # -##################################################################### - -def show_home_page(): - - image_path = "logo.png" - - # CSS to hide full-screen button - hide_img_fs = ''' - - ''' - - # Define column layout for centering image - left_co, cent_co, last_co = st.columns([2.5, 8, 2.5]) - with cent_co: - st.image(image_path, use_column_width="auto") - - # Apply CSS to hide full-screen button - st.markdown(hide_img_fs, unsafe_allow_html=True) - -# -- Welcome Message Stuff - - st.markdown(""" - lusSTR is a tool written in Python to convert Next Generation Sequencing (NGS) data of forensic STR loci to different sequence representations (sequence bracketed form) and allele designations (CE allele, LUS/LUS+ alleles) for ease in downstream analyses. - For more information on LusSTR, visit our [GitHub page](https://github.com/bioforensics/lusSTR/tree/master). - """, unsafe_allow_html=True) - - st.info('Please Select One of the Tabs Above to Get Started on Processing Your Data!') - -##################################################################### -# STR WORKFLOW # -##################################################################### - -##################################################################### -# Specify STR Settings Which Will Be Used to Generate Config File # -##################################################################### - -def show_STR_page(): - - st.title("STR Workflow") - st.info('Please Select STR Settings Below for LusSTR! For Information Regarding the Settings, See the How to Use Tab.') - - # Input File Specification - st.subheader("Input Files Selection") - - # Ask user if submitting a directory or individual file - st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") - input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) - - # Initialize session state if not already initialized - if 'samp_input' not in st.session_state: - st.session_state.samp_input = None - - # Logic for Path Picker based on user's input option - - if input_option == "Directory with Multiple Files": - st.write('Please select a folder:') - clicked = st.button('Folder Picker') - if clicked: - dirname = folder_picker_dialog() - #st.text_input('You Selected The Following folder:', dirname) - st.session_state.samp_input = dirname - - else: - st.write('Please select a file:') - clicked_file = st.button('File Picker') - if clicked_file: - filename = file_picker_dialog() - #st.text_input('You Selected The Following file:', filename) - st.session_state.samp_input = filename - - # Display The Selected Path - if st.session_state.samp_input: - st.text_input("Location Of Your Input File(s):", st.session_state.samp_input) - - # Store the Selected Path to Reference in Config - samp_input = st.session_state.samp_input - -##################################################################### -# STR: General Software Settings to Generate Config File # -##################################################################### - - st.subheader("General Software") - - analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor', 'GeneMarker HTS': 'genemarker'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3", "GeneMarker HTS"], help="Indicate the analysis software used prior to lusSTR sex.")] - - sex = st.checkbox("Include sex-chromosome STRs", help = "Check the box if yes, otherwise leave unchecked.") - - output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") - -##################################################################### -# STR: Convert Settings to Generate Config File # -##################################################################### - - st.subheader("Convert Settings") - - kit = {'ForenSeq Signature Prep': 'forenseq', 'PowerSeq 46GY': 'powerseq'}[st.selectbox("Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"])] - - nocombine = st.checkbox("Do Not Combine Identical Sequences") - -##################################################################### -# STP: Filter Settings to Generate Config File # -##################################################################### - - st.subheader("Filter Settings") - - output_type = {'STRmix': 'strmix', 'EuroForMix': 'efm', 'MPSproto': 'mpsproto'}[st.selectbox("Output Type", options=["STRmix", "EuroForMix", "MPSproto"])] - - profile_type = {'Evidence': 'evidence', 'Reference': 'reference'}[st.selectbox("Profile Type", options=["Evidence", "Reference"])] - - data_type = {'Sequence': 'ngs', 'CE allele': 'ce', 'LUS+ allele': 'lusplus'}[st.selectbox("Data Type", options=["Sequence", "CE allele", "LUS+ allele"])] - - info = st.checkbox("Create Allele Information File") - - separate = st.checkbox("Create Separate Files for Samples", help = "If True, Will Create Individual Files for Samples; If False, Will Create One File with all Samples.") - - nofilters = st.checkbox("Skip all filtering steps", help = "Skip all Filtering Steps; Will Still Create EFM/MPSproto/STRmix Output Files") - - strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicates the Strand Orientation in which to Report the Sequence in the Final Output Table for STRmix NGS only.")] - -##################################################################### -# STR: Specify Working Directory # -##################################################################### - - st.subheader("Set Working Directory") - - # Initialize session state if not already initialized - if 'wd_dirname' not in st.session_state: - st.session_state.wd_dirname = None - - clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') - if clicked_wd: - wd = folder_picker_dialog() - st.session_state.wd_dirname = wd - - # Display selected path - if st.session_state.wd_dirname: - st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) - - # Store Selected Path to Reference in Config - wd_dirname = st.session_state.wd_dirname - -##################################################################### -# STR: Generate Config File Based on Settings # -##################################################################### - - # Submit Button Instance - if st.button("Submit"): - - # Check if all required fields are filled - if analysis_software and samp_input and output and wd_dirname: - - # Validate output prefix - if not validate_prefix(output): - st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") - st.stop() # Stop execution if prefix is invalid - - # Display loading spinner (Continuing Process Checks Above Were Passed) - with st.spinner("Processing Your Data..."): - - # Construct config data - - config_data = { - "analysis_software": analysis_software, - "sex": sex, - "samp_input": samp_input, - "output": output, - "kit": kit, - "nocombine": nocombine, - "output_type": output_type, - "profile_type": profile_type, - "data_type": data_type, - "info": info, - "separate": separate, - "nofilters": nofilters, - "strand": strand - } - - # Generate YAML config file - generate_config_file(config_data, wd_dirname, "STR") - - # Subprocess lusSTR commands - command = ["lusstr", "strs", "all"] - - # Specify WD to lusSTR - if wd_dirname: - command.extend(["-w", wd_dirname + "/"]) - - # Run lusSTR command in terminal - try: - subprocess.run(command, check=True) - st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") - except subprocess.CalledProcessError as e: - st.error(f"Error: {e}") - st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") - - else: - st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") - -##################################################################### -# SNP WORKFLOW # -##################################################################### - -##################################################################### -# Specify SNP Settings Which Will Be Used to Generate Config File # -##################################################################### - -def show_SNP_page(): - - st.title("SNP Workflow") - st.info('Please Select SNP Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab.') - - # Input File Specification - st.subheader("Input Files Selection") - - # Ask user if submitting a directory or individual file - st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") - input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) - - # Initialize session state if not already initialized - if 'samp_input' not in st.session_state: - st.session_state.samp_input = None - - # Logic for Path Picker based on user's input option - - if input_option == "Directory with Multiple Files": - st.write('Please select a folder:') - clicked = st.button('Folder Picker') - if clicked: - dirname = folder_picker_dialog() - #st.text_input('You Selected The Following folder:', dirname) - st.session_state.samp_input = dirname - - else: - st.write('Please select a file:') - clicked_file = st.button('File Picker') - if clicked_file: - filename = file_picker_dialog() - #st.text_input('You Selected The Following file:', filename) - st.session_state.samp_input = filename - - # Display The Selected Path - if st.session_state.samp_input: - st.text_input("Location Of Your Input File(s):", st.session_state.samp_input) - - # Store Selected Path to Reference in Config - samp_input = st.session_state.samp_input - -##################################################################### -# SNP: General Software Settings to Generate Config File # -##################################################################### - - st.subheader("General Software") - - analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3"], help="Indicate the analysis software used prior to lusSTR sex.")] - - output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") - - kit = {'Signature Prep': 'sigprep', 'Kintelligence': 'kintelligence'}[st.selectbox("Library Preparation Kit", options=["Signature Prep", "Kintelligence"])] - -##################################################################### -# SNP: Format Settings to Generate Config File # -##################################################################### - - st.subheader("Format Settings") - - # -- Select Type (Unique to SNP Workflow) - types_mapping = {"Identify SNPs Only": "i", "Phenotype Only": "p", "Ancestry Only": "a", "All": "all"} - selected_types = st.multiselect("Select Types:", options=types_mapping.keys(), help="Please Select a Choice or any Combination") - types_string = "all" if "All" in selected_types else ", ".join(types_mapping.get(t, t) for t in selected_types) - - #if selected_types: - # st.text_input("You Selected:", types_string) - - # -- Filter - nofilters = st.checkbox("Skip all filtering steps", help = "If no filtering is desired at the format step; if False, will remove any allele designated as Not Typed") - -##################################################################### -# SNP: Convert Settings to Generate Config File # -##################################################################### - - st.subheader("Convert Settings") - - separate = st.checkbox("Create Separate Files for Samples", help = "If want to separate samples into individual files for use in EFM") - - strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicate which orientation to report the alleles for the SigPrep SNPs.")] - - # Analytical threshold value - thresh = st.number_input("Analytical threshold value:", value=0.03, step=0.01, min_value = 0.0) - -##################################################################### -# SNP: Specify a Reference File if User Has One # -##################################################################### - - st.subheader("Specify a Reference File (Optional)") - - if 'reference' not in st.session_state: - st.session_state.reference = None - - clicked_ref = st.button('Please Specify Your Reference File If You Have One', help = "List IDs of the samples to be run as references in EFM; default is no reference samples") - if clicked_ref: - ref = file_picker_dialog() - st.session_state.reference = ref - - # Display Path to Selected Reference File - if st.session_state.reference: - st.text_input("Your Specified Reference File:", st.session_state.reference) - - # Store Selected Path to Reference in Config - reference = st.session_state.reference - -##################################################################### -# SNP: Specify Working Directory # -##################################################################### - - st.subheader("Set Working Directory") - - # Initialize session state if not already initialized - if 'wd_dirname' not in st.session_state: - st.session_state.wd_dirname = None - - clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') - if clicked_wd: - wd = folder_picker_dialog() - st.session_state.wd_dirname = wd - - # Display selected path - if st.session_state.wd_dirname: - st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) - - # Store Selected Path to Reference in Config - wd_dirname = st.session_state.wd_dirname - -##################################################################### -# SNP: Generate Config File Based on Settings # -##################################################################### - - # Submit Button Instance - if st.button("Submit"): - - # Check if all required fields are filled - if analysis_software and samp_input and output and wd_dirname: - - # Validate output prefix - if not validate_prefix(output): - st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") - st.stop() # Stop execution if prefix is invalid - - # Display loading spinner (Continuing Process Checks Above Were Passed) - with st.spinner("Processing Your Data..."): - - # Construct config data - - config_data = { - "analysis_software": analysis_software, - "samp_input": samp_input, - "output": output, - "kit": kit, - "types": types_string, - "thresh": thresh, - "separate": separate, - "nofilter": nofilters, - "strand": strand, - "references": None # Default value is None - } - - # If a reference file was specified, add to config - if reference: - config_data["references"] = reference - - # Generate YAML config file - generate_config_file(config_data, wd_dirname, "SNP") - - # Subprocess lusSTR commands - command = ["lusstr", "snps", "all"] - - # Specify WD to lusSTR - if wd_dirname: - command.extend(["-w", wd_dirname + "/"]) - - # Run lusSTR command in terminal - try: - subprocess.run(command, check=True) - st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") - except subprocess.CalledProcessError as e: - st.error(f"Error: {e}") - st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") - - else: - st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") - -##################################################################### -# How To Use Page # -##################################################################### - -def show_how_to_use_page(): - - st.title("Common Errors and Best Practices for Using lusSTR") - - st.header("1. File/Folder Path Formatting") - - st.write("Please ensure that the displayed path accurately reflects your selection. When using the file or folder picker, navigate to the desired location and click 'OK' to confirm your selection.") - - st.header("2. Specifying Output Prefix") - - st.write("The purpose of specifying the output prefix is for lusSTR to create result files and folders with that prefix in your working directory. Please ensure that you are following proper file naming formatting and rules when specifying this prefix. Avoid using characters such as '/', '', '.', and others. Note: To avoid potential errors, you can simply use the default placeholder for output.") - - st.code("Incorrect: 'working_directory/subfolder/subfolder'\nCorrect: working_directory/output # or just output, since you will likely already be in the working directory when specifying it before submitting.") - - st.write("Note that some result files may be saved directly in the working directory with the specified prefix, while others will be populated in a folder labeled with the prefix in your working directory.") - st.write("Be aware of this behavior when checking for output files.") - - st.header("3. Specifying Working Directory") - st.write("Please Ensure That You Properly Specify a Working Directory. This is where all lusSTR output files will be saved. To avoid potential errors, specifying a working directory is required.") - - st.title("About lusSTR") - - st.markdown(""" - - **_lusSTR Accommodates Four Different Input Formats:_** - - (1) UAS Sample Details Report, UAS Sample Report, and UAS Phenotype Report (for SNP processing) in .xlsx format (a single file or directory containing multiple files) - - (2) STRait Razor v3 output with one sample per file (a single file or directory containing multiple files) - - (3) GeneMarker v2.6 output (a single file or directory containing multiple files) - - (4) Sample(s) sequences in CSV format; first four columns must be Locus, NumReads, Sequence, SampleID; Optional last two columns can be Project and Analysis IDs. - - - """, unsafe_allow_html = True) - -##################################################################### -# Contact Page # -##################################################################### - -def show_contact_page(): - st.title("Contact Us") - st.write("For any questions or issues, please contact rebecca.mitchell@st.dhs.gov, daniel.standage@st.dhs.gov, or s.h.syed@email.msmary.edu") - -##################################################################### -# lusSTR RUN # -##################################################################### - -if __name__ == "__main__": - main() - -def subparser(subparsers): - parser = subparsers.add_parser("gui", help="Launch the Streamlit GUI") - parser.set_defaults(func=main) +# ------------------------------------------------------------------------------------------------- +# Copyright (c) 2024, DHS. +# +# This file is part of lusSTR (http://github.com/bioforensics/lusSTR) and is licensed under +# the BSD license: see LICENSE.txt. +# +# This software was prepared for the Department of Homeland Security (DHS) by the Battelle National +# Biodefense Institute, LLC (BNBI) as part of contract HSHQDC-15-C-00064 to manage and operate the +# National Biodefense Analysis and Countermeasures Center (NBACC), a Federally Funded Research and +# Development Center. +# ------------------------------------------------------------------------------------------------- +################################################################# +# Importing Necessary Packages # +################################################################# + +import importlib.resources +import streamlit as st +from streamlit_option_menu import option_menu +import yaml +import subprocess +import os +import re + +# ------ Packages For File/Folder Directory Selection --------- # + +import tkinter as tk +from tkinter import filedialog + +# Create a global Tkinter root window +root = tk.Tk() +root.withdraw() # Hide the root window + +################################################################# +# Functions # +################################################################# + +# ------------ Function to Generate config.yaml File ---------- # + +def generate_config_file(config_data, working_directory, workflow_type): + if workflow_type == "STR": + config_filename = 'config.yaml' + elif workflow_type == "SNP": + config_filename = 'snp_config.yaml' + else: + raise ValueError("Invalid workflow type. Please specify either 'STR' or 'SNP'.") + + config_path = os.path.join(working_directory, config_filename) + with open(config_path, 'w') as file: + yaml.dump(config_data, file) + +# ------------ Function for folder selection ------------------ # + +def folder_picker_dialog(): + folder_path = filedialog.askdirectory(master=root) + return folder_path + +# ------- Function for individual file selection -------------- # + +def file_picker_dialog(): + file_path = filedialog.askopenfilename(master=root) + return file_path + +# ---- Function to validate prefix for output folder ---------- # + +def validate_prefix(prefix): + if re.match(r'^[A-Za-z0-9_-]+$', prefix): # Allow alphanumeric characters, underscore, and hyphen + return True + else: + return False + +################################################################# +# Front-End Logic For Navigation Bar # +################################################################# + +def main(): + + # Page Layout (Theme and Fonts have been established in .streamlit/config.toml) + st.set_page_config(layout='wide', initial_sidebar_state='collapsed') + + # Creating Navigation Bar + + selected = option_menu( + menu_title=None, + options=["Home", "STR", "SNP", "How to Use", "Contact"], + icons=["house", "gear", "gear-fill", "book", "envelope"], + menu_icon="cast", + default_index=0, + orientation="horizontal" + ) + + if selected == "Home": + show_home_page() + + elif selected == "STR": + show_STR_page() + + elif selected == "SNP": + show_SNP_page() + + elif selected == "How to Use": + show_how_to_use_page() + + elif selected == "Contact": + show_contact_page() + +##################################################################### +# lusSTR Home Page # +##################################################################### + +def show_home_page(): + + image_path = importlib.resources.files("lusSTR") / "cli" / "logo.png" + + # CSS to hide full-screen button + hide_img_fs = ''' + + ''' + + # Define column layout for centering image + left_co, cent_co, last_co = st.columns([2.5, 8, 2.5]) + with cent_co: + st.image(str(image_path), use_column_width="auto") + + # Apply CSS to hide full-screen button + st.markdown(hide_img_fs, unsafe_allow_html=True) + +# -- Welcome Message Stuff + + st.markdown(""" + lusSTR is a tool written in Python to convert Next Generation Sequencing (NGS) data of forensic STR loci to different sequence representations (sequence bracketed form) and allele designations (CE allele, LUS/LUS+ alleles) for ease in downstream analyses. + For more information on LusSTR, visit our [GitHub page](https://github.com/bioforensics/lusSTR/tree/master). + """, unsafe_allow_html=True) + + st.info('Please Select One of the Tabs Above to Get Started on Processing Your Data!') + +##################################################################### +# STR WORKFLOW # +##################################################################### + +##################################################################### +# Specify STR Settings Which Will Be Used to Generate Config File # +##################################################################### + +def show_STR_page(): + + st.title("STR Workflow") + st.info('Please Select STR Settings Below for LusSTR! For Information Regarding the Settings, See the How to Use Tab.') + + # Input File Specification + st.subheader("Input Files Selection") + + # Ask user if submitting a directory or individual file + st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") + input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) + + # Initialize session state if not already initialized + if 'samp_input' not in st.session_state: + st.session_state.samp_input = None + + # Logic for Path Picker based on user's input option + + if input_option == "Directory with Multiple Files": + st.write('Please select a folder:') + clicked = st.button('Folder Picker') + if clicked: + dirname = folder_picker_dialog() + #st.text_input('You Selected The Following folder:', dirname) + st.session_state.samp_input = dirname + + else: + st.write('Please select a file:') + clicked_file = st.button('File Picker') + if clicked_file: + filename = file_picker_dialog() + #st.text_input('You Selected The Following file:', filename) + st.session_state.samp_input = filename + + # Display The Selected Path + if st.session_state.samp_input: + st.text_input("Location Of Your Input File(s):", st.session_state.samp_input) + + # Store the Selected Path to Reference in Config + samp_input = st.session_state.samp_input + +##################################################################### +# STR: General Software Settings to Generate Config File # +##################################################################### + + st.subheader("General Software") + + analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor', 'GeneMarker HTS': 'genemarker'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3", "GeneMarker HTS"], help="Indicate the analysis software used prior to lusSTR sex.")] + + sex = st.checkbox("Include sex-chromosome STRs", help = "Check the box if yes, otherwise leave unchecked.") + + output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") + +##################################################################### +# STR: Convert Settings to Generate Config File # +##################################################################### + + st.subheader("Convert Settings") + + kit = {'ForenSeq Signature Prep': 'forenseq', 'PowerSeq 46GY': 'powerseq'}[st.selectbox("Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"])] + + nocombine = st.checkbox("Do Not Combine Identical Sequences") + +##################################################################### +# STP: Filter Settings to Generate Config File # +##################################################################### + + st.subheader("Filter Settings") + + output_type = {'STRmix': 'strmix', 'EuroForMix': 'efm', 'MPSproto': 'mpsproto'}[st.selectbox("Output Type", options=["STRmix", "EuroForMix", "MPSproto"])] + + profile_type = {'Evidence': 'evidence', 'Reference': 'reference'}[st.selectbox("Profile Type", options=["Evidence", "Reference"])] + + data_type = {'Sequence': 'ngs', 'CE allele': 'ce', 'LUS+ allele': 'lusplus'}[st.selectbox("Data Type", options=["Sequence", "CE allele", "LUS+ allele"])] + + info = st.checkbox("Create Allele Information File") + + separate = st.checkbox("Create Separate Files for Samples", help = "If True, Will Create Individual Files for Samples; If False, Will Create One File with all Samples.") + + nofilters = st.checkbox("Skip all filtering steps", help = "Skip all Filtering Steps; Will Still Create EFM/MPSproto/STRmix Output Files") + + strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicates the Strand Orientation in which to Report the Sequence in the Final Output Table for STRmix NGS only.")] + +##################################################################### +# STR: Specify Working Directory # +##################################################################### + + st.subheader("Set Working Directory") + + # Initialize session state if not already initialized + if 'wd_dirname' not in st.session_state: + st.session_state.wd_dirname = None + + clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') + if clicked_wd: + wd = folder_picker_dialog() + st.session_state.wd_dirname = wd + + # Display selected path + if st.session_state.wd_dirname: + st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) + + # Store Selected Path to Reference in Config + wd_dirname = st.session_state.wd_dirname + +##################################################################### +# STR: Generate Config File Based on Settings # +##################################################################### + + # Submit Button Instance + if st.button("Submit"): + + # Check if all required fields are filled + if analysis_software and samp_input and output and wd_dirname: + + # Validate output prefix + if not validate_prefix(output): + st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") + st.stop() # Stop execution if prefix is invalid + + # Display loading spinner (Continuing Process Checks Above Were Passed) + with st.spinner("Processing Your Data..."): + + # Construct config data + + config_data = { + "analysis_software": analysis_software, + "sex": sex, + "samp_input": samp_input, + "output": output, + "kit": kit, + "nocombine": nocombine, + "output_type": output_type, + "profile_type": profile_type, + "data_type": data_type, + "info": info, + "separate": separate, + "nofilters": nofilters, + "strand": strand + } + + # Generate YAML config file + generate_config_file(config_data, wd_dirname, "STR") + + # Subprocess lusSTR commands + command = ["lusstr", "strs", "all"] + + # Specify WD to lusSTR + if wd_dirname: + command.extend(["-w", wd_dirname + "/"]) + + # Run lusSTR command in terminal + try: + subprocess.run(command, check=True) + st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") + except subprocess.CalledProcessError as e: + st.error(f"Error: {e}") + st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") + + else: + st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") + +##################################################################### +# SNP WORKFLOW # +##################################################################### + +##################################################################### +# Specify SNP Settings Which Will Be Used to Generate Config File # +##################################################################### + +def show_SNP_page(): + + st.title("SNP Workflow") + st.info('Please Select SNP Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab.') + + # Input File Specification + st.subheader("Input Files Selection") + + # Ask user if submitting a directory or individual file + st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") + input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) + + # Initialize session state if not already initialized + if 'samp_input' not in st.session_state: + st.session_state.samp_input = None + + # Logic for Path Picker based on user's input option + + if input_option == "Directory with Multiple Files": + st.write('Please select a folder:') + clicked = st.button('Folder Picker') + if clicked: + dirname = folder_picker_dialog() + #st.text_input('You Selected The Following folder:', dirname) + st.session_state.samp_input = dirname + + else: + st.write('Please select a file:') + clicked_file = st.button('File Picker') + if clicked_file: + filename = file_picker_dialog() + #st.text_input('You Selected The Following file:', filename) + st.session_state.samp_input = filename + + # Display The Selected Path + if st.session_state.samp_input: + st.text_input("Location Of Your Input File(s):", st.session_state.samp_input) + + # Store Selected Path to Reference in Config + samp_input = st.session_state.samp_input + +##################################################################### +# SNP: General Software Settings to Generate Config File # +##################################################################### + + st.subheader("General Software") + + analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3"], help="Indicate the analysis software used prior to lusSTR sex.")] + + output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") + + kit = {'Signature Prep': 'sigprep', 'Kintelligence': 'kintelligence'}[st.selectbox("Library Preparation Kit", options=["Signature Prep", "Kintelligence"])] + +##################################################################### +# SNP: Format Settings to Generate Config File # +##################################################################### + + st.subheader("Format Settings") + + # -- Select Type (Unique to SNP Workflow) + types_mapping = {"Identify SNPs Only": "i", "Phenotype Only": "p", "Ancestry Only": "a", "All": "all"} + selected_types = st.multiselect("Select Types:", options=types_mapping.keys(), help="Please Select a Choice or any Combination") + types_string = "all" if "All" in selected_types else ", ".join(types_mapping.get(t, t) for t in selected_types) + + #if selected_types: + # st.text_input("You Selected:", types_string) + + # -- Filter + nofilters = st.checkbox("Skip all filtering steps", help = "If no filtering is desired at the format step; if False, will remove any allele designated as Not Typed") + +##################################################################### +# SNP: Convert Settings to Generate Config File # +##################################################################### + + st.subheader("Convert Settings") + + separate = st.checkbox("Create Separate Files for Samples", help = "If want to separate samples into individual files for use in EFM") + + strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicate which orientation to report the alleles for the SigPrep SNPs.")] + + # Analytical threshold value + thresh = st.number_input("Analytical threshold value:", value=0.03, step=0.01, min_value = 0.0) + +##################################################################### +# SNP: Specify a Reference File if User Has One # +##################################################################### + + st.subheader("Specify a Reference File (Optional)") + + if 'reference' not in st.session_state: + st.session_state.reference = None + + clicked_ref = st.button('Please Specify Your Reference File If You Have One', help = "List IDs of the samples to be run as references in EFM; default is no reference samples") + if clicked_ref: + ref = file_picker_dialog() + st.session_state.reference = ref + + # Display Path to Selected Reference File + if st.session_state.reference: + st.text_input("Your Specified Reference File:", st.session_state.reference) + + # Store Selected Path to Reference in Config + reference = st.session_state.reference + +##################################################################### +# SNP: Specify Working Directory # +##################################################################### + + st.subheader("Set Working Directory") + + # Initialize session state if not already initialized + if 'wd_dirname' not in st.session_state: + st.session_state.wd_dirname = None + + clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') + if clicked_wd: + wd = folder_picker_dialog() + st.session_state.wd_dirname = wd + + # Display selected path + if st.session_state.wd_dirname: + st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) + + # Store Selected Path to Reference in Config + wd_dirname = st.session_state.wd_dirname + +##################################################################### +# SNP: Generate Config File Based on Settings # +##################################################################### + + # Submit Button Instance + if st.button("Submit"): + + # Check if all required fields are filled + if analysis_software and samp_input and output and wd_dirname: + + # Validate output prefix + if not validate_prefix(output): + st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") + st.stop() # Stop execution if prefix is invalid + + # Display loading spinner (Continuing Process Checks Above Were Passed) + with st.spinner("Processing Your Data..."): + + # Construct config data + + config_data = { + "analysis_software": analysis_software, + "samp_input": samp_input, + "output": output, + "kit": kit, + "types": types_string, + "thresh": thresh, + "separate": separate, + "nofilter": nofilters, + "strand": strand, + "references": None # Default value is None + } + + # If a reference file was specified, add to config + if reference: + config_data["references"] = reference + + # Generate YAML config file + generate_config_file(config_data, wd_dirname, "SNP") + + # Subprocess lusSTR commands + command = ["lusstr", "snps", "all"] + + # Specify WD to lusSTR + if wd_dirname: + command.extend(["-w", wd_dirname + "/"]) + + # Run lusSTR command in terminal + try: + subprocess.run(command, check=True) + st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") + except subprocess.CalledProcessError as e: + st.error(f"Error: {e}") + st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") + + else: + st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") + +##################################################################### +# How To Use Page # +##################################################################### + +def show_how_to_use_page(): + + st.title("Common Errors and Best Practices for Using lusSTR") + + st.header("1. File/Folder Path Formatting") + + st.write("Please ensure that the displayed path accurately reflects your selection. When using the file or folder picker, navigate to the desired location and click 'OK' to confirm your selection.") + + st.header("2. Specifying Output Prefix") + + st.write("The purpose of specifying the output prefix is for lusSTR to create result files and folders with that prefix in your working directory. Please ensure that you are following proper file naming formatting and rules when specifying this prefix. Avoid using characters such as '/', '', '.', and others. Note: To avoid potential errors, you can simply use the default placeholder for output.") + + st.code("Incorrect: 'working_directory/subfolder/subfolder'\nCorrect: working_directory/output # or just output, since you will likely already be in the working directory when specifying it before submitting.") + + st.write("Note that some result files may be saved directly in the working directory with the specified prefix, while others will be populated in a folder labeled with the prefix in your working directory.") + st.write("Be aware of this behavior when checking for output files.") + + st.header("3. Specifying Working Directory") + st.write("Please Ensure That You Properly Specify a Working Directory. This is where all lusSTR output files will be saved. To avoid potential errors, specifying a working directory is required.") + + st.title("About lusSTR") + + st.markdown(""" + + **_lusSTR Accommodates Four Different Input Formats:_** + + (1) UAS Sample Details Report, UAS Sample Report, and UAS Phenotype Report (for SNP processing) in .xlsx format (a single file or directory containing multiple files) + + (2) STRait Razor v3 output with one sample per file (a single file or directory containing multiple files) + + (3) GeneMarker v2.6 output (a single file or directory containing multiple files) + + (4) Sample(s) sequences in CSV format; first four columns must be Locus, NumReads, Sequence, SampleID; Optional last two columns can be Project and Analysis IDs. + + + """, unsafe_allow_html = True) + +##################################################################### +# Contact Page # +##################################################################### + +def show_contact_page(): + st.title("Contact Us") + st.write("For any questions or issues, please contact rebecca.mitchell@st.dhs.gov, daniel.standage@st.dhs.gov, or s.h.syed@email.msmary.edu") + +##################################################################### +# lusSTR RUN # +##################################################################### + +if __name__ == "__main__": + main() + +def subparser(subparsers): + parser = subparsers.add_parser("gui", help="Launch the Streamlit GUI") + parser.set_defaults(func=main) From fa935dd88ad6c811d97a8e35c9d7f00e0d823e7a Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Mon, 1 Jul 2024 12:54:47 -0400 Subject: [PATCH 03/17] fixed bug in file/folder pickers --- lusSTR/cli/gui.py | 32 +++++++++++++++++++++++++++---- lusSTR/scripts/file_selector.py | 29 ++++++++++++++++++++++++++++ lusSTR/scripts/folder_selector.py | 29 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 lusSTR/scripts/file_selector.py create mode 100644 lusSTR/scripts/folder_selector.py diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index e8ffc468..c0d5a0c8 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -13,6 +13,7 @@ # Importing Necessary Packages # ################################################################# +import json import importlib.resources import streamlit as st from streamlit_option_menu import option_menu @@ -51,14 +52,36 @@ def generate_config_file(config_data, working_directory, workflow_type): # ------------ Function for folder selection ------------------ # def folder_picker_dialog(): - folder_path = filedialog.askdirectory(master=root) - return folder_path + script_path = importlib.resources.files("lusSTR") / "scripts" / "folder_selector.py" + result = subprocess.run(["python", script_path], capture_output=True, text=True) + if result.returncode == 0: + folder_data = json.loads(result.stdout) + folder_path = folder_data.get("folder_path") + if folder_path: + st.success(f"Selected Folder: {folder_path}") + return folder_path + + else: + st.error("No folder selected") + else: + st.error("Error selecting folder") # ------- Function for individual file selection -------------- # def file_picker_dialog(): - file_path = filedialog.askopenfilename(master=root) - return file_path + script_path = importlib.resources.files("lusSTR") / "scripts" / "file_selector.py" + result = subprocess.run(["python", script_path], capture_output=True, text=True) + if result.returncode == 0: + file_data = json.loads(result.stdout) + file_path = file_data.get("file_path") + if file_path: + st.success(f"Selected File: {file_path}") + return file_path + + else: + st.error("No folder selected") + else: + st.error("Error selecting folder") # ---- Function to validate prefix for output folder ---------- # @@ -103,6 +126,7 @@ def main(): elif selected == "Contact": show_contact_page() + ##################################################################### # lusSTR Home Page # ##################################################################### diff --git a/lusSTR/scripts/file_selector.py b/lusSTR/scripts/file_selector.py new file mode 100644 index 00000000..beff54de --- /dev/null +++ b/lusSTR/scripts/file_selector.py @@ -0,0 +1,29 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (c) 2024, DHS. +# +# This file is part of lusSTR (http://github.com/bioforensics/lusSTR) and is licensed under +# the BSD license: see LICENSE.txt. +# +# This software was prepared for the Department of Homeland Security (DHS) by the Battelle National +# Biodefense Institute, LLC (BNBI) as part of contract HSHQDC-15-C-00064 to manage and operate the +# National Biodefense Analysis and Countermeasures Center (NBACC), a Federally Funded Research and +# Development Center. +# ------------------------------------------------------------------------------------------------- + +import tkinter as tk +from tkinter import filedialog +import sys +import json + + +def select_file(): + root = tk.Tk() + root.withdraw() # Hide the main window + file_path = filedialog.askopenfilename() # Open the dialog to select a folder + if file_path: + print(json.dumps({"file_path": file_path})) + root.destroy() + + +if __name__ == "__main__": + select_file() diff --git a/lusSTR/scripts/folder_selector.py b/lusSTR/scripts/folder_selector.py new file mode 100644 index 00000000..fc554234 --- /dev/null +++ b/lusSTR/scripts/folder_selector.py @@ -0,0 +1,29 @@ +# ------------------------------------------------------------------------------------------------- +# Copyright (c) 2024, DHS. +# +# This file is part of lusSTR (http://github.com/bioforensics/lusSTR) and is licensed under +# the BSD license: see LICENSE.txt. +# +# This software was prepared for the Department of Homeland Security (DHS) by the Battelle National +# Biodefense Institute, LLC (BNBI) as part of contract HSHQDC-15-C-00064 to manage and operate the +# National Biodefense Analysis and Countermeasures Center (NBACC), a Federally Funded Research and +# Development Center. +# ------------------------------------------------------------------------------------------------- + +import tkinter as tk +from tkinter import filedialog +import sys +import json + + +def select_folder(): + root = tk.Tk() + root.withdraw() # Hide the main window + folder_path = filedialog.askdirectory() # Open the dialog to select a folder + if folder_path: + print(json.dumps({"folder_path": folder_path})) + root.destroy() + + +if __name__ == "__main__": + select_folder() From bed4277bade5d7db964df9637ab6356af5763e90 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 07:10:38 -0400 Subject: [PATCH 04/17] updating GUI settings [skip ci] --- lusSTR/cli/gui.py | 58 ++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index c0d5a0c8..728e8e02 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -58,9 +58,7 @@ def folder_picker_dialog(): folder_data = json.loads(result.stdout) folder_path = folder_data.get("folder_path") if folder_path: - st.success(f"Selected Folder: {folder_path}") return folder_path - else: st.error("No folder selected") else: @@ -75,9 +73,7 @@ def file_picker_dialog(): file_data = json.loads(result.stdout) file_path = file_data.get("file_path") if file_path: - st.success(f"Selected File: {file_path}") return file_path - else: st.error("No folder selected") else: @@ -154,8 +150,8 @@ def show_home_page(): # -- Welcome Message Stuff st.markdown(""" - lusSTR is a tool written in Python to convert Next Generation Sequencing (NGS) data of forensic STR loci to different sequence representations (sequence bracketed form) and allele designations (CE allele, LUS/LUS+ alleles) for ease in downstream analyses. - For more information on LusSTR, visit our [GitHub page](https://github.com/bioforensics/lusSTR/tree/master). + lusSTR is an end-to-end workflow for processing human forensic data (STRs and SNPs) derived from Next Generation Sequencing (NGS) data for use in probabilistic genotyping software. + For more information on lusSTR, visit our [GitHub page](https://github.com/bioforensics/lusSTR). """, unsafe_allow_html=True) st.info('Please Select One of the Tabs Above to Get Started on Processing Your Data!') @@ -171,14 +167,14 @@ def show_home_page(): def show_STR_page(): st.title("STR Workflow") - st.info('Please Select STR Settings Below for LusSTR! For Information Regarding the Settings, See the How to Use Tab.') + st.info('Please Select STR Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab.') # Input File Specification st.subheader("Input Files Selection") # Ask user if submitting a directory or individual file - st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") - input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) + st.info("Please Indicate If You Are Providing An Individual Input File or a Folder Containing Multiple Input Files") + input_option = st.radio("Select Input Option:", ("Individual File", "Folder with Multiple Files")) # Initialize session state if not already initialized if 'samp_input' not in st.session_state: @@ -186,7 +182,7 @@ def show_STR_page(): # Logic for Path Picker based on user's input option - if input_option == "Directory with Multiple Files": + if input_option == "Folder with Multiple Files": st.write('Please select a folder:') clicked = st.button('Folder Picker') if clicked: @@ -213,23 +209,19 @@ def show_STR_page(): # STR: General Software Settings to Generate Config File # ##################################################################### - st.subheader("General Software") - - analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor', 'GeneMarker HTS': 'genemarker'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3", "GeneMarker HTS"], help="Indicate the analysis software used prior to lusSTR sex.")] + st.subheader("General Settings") - sex = st.checkbox("Include sex-chromosome STRs", help = "Check the box if yes, otherwise leave unchecked.") + col1, col2, col3, col4, col5 = st.columns(5) - output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") + analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor', 'GeneMarker HTS': 'genemarker'}[col1.selectbox("Analysis Software", options=["UAS", "STRait Razor v3", "GeneMarker HTS"], help="Indicate the analysis software used prior to lusSTR.")] -##################################################################### -# STR: Convert Settings to Generate Config File # -##################################################################### + sex = st.checkbox("Include X- and Y-STRs", help = "Check the box to include X- and Y-STRs, otherwise leave unchecked.") - st.subheader("Convert Settings") + output = col2.text_input("Output File Name", "lusstr_output", help = "Please specify a name for the created files.") - kit = {'ForenSeq Signature Prep': 'forenseq', 'PowerSeq 46GY': 'powerseq'}[st.selectbox("Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"])] + kit = {'ForenSeq Signature Prep': 'forenseq', 'PowerSeq 46GY': 'powerseq'}[col3.selectbox("Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"])] - nocombine = st.checkbox("Do Not Combine Identical Sequences") + nocombine = st.checkbox("Do Not Combine Identical Sequences", help = "If using STRait Razor data, by default, identical sequences (after removing flanking sequences) are combined and reads are summed. Checking this will not combine identical sequences.") ##################################################################### # STP: Filter Settings to Generate Config File # @@ -237,38 +229,42 @@ def show_STR_page(): st.subheader("Filter Settings") - output_type = {'STRmix': 'strmix', 'EuroForMix': 'efm', 'MPSproto': 'mpsproto'}[st.selectbox("Output Type", options=["STRmix", "EuroForMix", "MPSproto"])] + col1, col2, col3, col4, col5 = st.columns(5) + + output_type = {'STRmix': 'strmix', 'EuroForMix': 'efm', 'MPSproto': 'mpsproto'}[col1.selectbox("Probabilistic Genotyping Software", options=["STRmix", "EuroForMix", "MPSproto"], help="Select which probabilistic genotyping software files to create")] - profile_type = {'Evidence': 'evidence', 'Reference': 'reference'}[st.selectbox("Profile Type", options=["Evidence", "Reference"])] + profile_type = {'Evidence': 'evidence', 'Reference': 'reference'}[col2.selectbox("Profile Type", options=["Evidence", "Reference"], help="Select the file type (format) to create for the probabilistic genotyping software.")] - data_type = {'Sequence': 'ngs', 'CE allele': 'ce', 'LUS+ allele': 'lusplus'}[st.selectbox("Data Type", options=["Sequence", "CE allele", "LUS+ allele"])] + data_type = {'Sequence': 'ngs', 'CE allele': 'ce', 'LUS+ allele': 'lusplus'}[col3.selectbox("Data Type", options=["Sequence", "CE allele", "LUS+ allele"], help="Select the allele type used to determine sequence type (belowAT, stutter or typed) and used in the final output file.")] - info = st.checkbox("Create Allele Information File") + info = st.checkbox("Create Allele Information File", value=True, help="Create file containing information about each sequence, including sequence type (belowAT, stutter or typed), stuttering sequence information and metrics involving stutter and noise.") - separate = st.checkbox("Create Separate Files for Samples", help = "If True, Will Create Individual Files for Samples; If False, Will Create One File with all Samples.") + separate = st.checkbox("Create Separate Files for Samples", help = "If checked, will create individual files for samples; If unchecked, will create one file with all samples.") - nofilters = st.checkbox("Skip all filtering steps", help = "Skip all Filtering Steps; Will Still Create EFM/MPSproto/STRmix Output Files") + nofilters = st.checkbox("Skip all filtering steps", help = "Will not perform filtering; will still create EFM/MPSproto/STRmix output files") - strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicates the Strand Orientation in which to Report the Sequence in the Final Output Table for STRmix NGS only.")] + strand = {'UAS': 'uas', 'Forward Strand': 'forward'}[col4.selectbox("Strand Orientation", options=["UAS", "Forward Strand"], help="Indicates the strand orientation in which to report the sequence in the final output table; for STRmix NGS only.")] ##################################################################### # STR: Specify Working Directory # ##################################################################### - st.subheader("Set Working Directory") + st.subheader("Set Output Folder") + + col1, col2, col3, col4, col5 = st.columns(5) # Initialize session state if not already initialized if 'wd_dirname' not in st.session_state: st.session_state.wd_dirname = None - clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') + clicked_wd = col1.button('Please Select An Output Folder') if clicked_wd: wd = folder_picker_dialog() st.session_state.wd_dirname = wd # Display selected path if st.session_state.wd_dirname: - st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) + st.text_input("Your Specified Output Folder:", st.session_state.wd_dirname) # Store Selected Path to Reference in Config wd_dirname = st.session_state.wd_dirname From c5ea0728b4544be466eb0e83750659f43bcbfb89 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 07:12:33 -0400 Subject: [PATCH 05/17] updating GUI settings [skip ci] --- lusSTR/cli/gui.py | 394 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 280 insertions(+), 114 deletions(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 728e8e02..1047976c 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -37,20 +37,23 @@ # ------------ Function to Generate config.yaml File ---------- # + def generate_config_file(config_data, working_directory, workflow_type): if workflow_type == "STR": - config_filename = 'config.yaml' + config_filename = "config.yaml" elif workflow_type == "SNP": - config_filename = 'snp_config.yaml' + config_filename = "snp_config.yaml" else: raise ValueError("Invalid workflow type. Please specify either 'STR' or 'SNP'.") config_path = os.path.join(working_directory, config_filename) - with open(config_path, 'w') as file: + with open(config_path, "w") as file: yaml.dump(config_data, file) + # ------------ Function for folder selection ------------------ # + def folder_picker_dialog(): script_path = importlib.resources.files("lusSTR") / "scripts" / "folder_selector.py" result = subprocess.run(["python", script_path], capture_output=True, text=True) @@ -64,8 +67,10 @@ def folder_picker_dialog(): else: st.error("Error selecting folder") + # ------- Function for individual file selection -------------- # + def file_picker_dialog(): script_path = importlib.resources.files("lusSTR") / "scripts" / "file_selector.py" result = subprocess.run(["python", script_path], capture_output=True, text=True) @@ -79,22 +84,28 @@ def file_picker_dialog(): else: st.error("Error selecting folder") + # ---- Function to validate prefix for output folder ---------- # + def validate_prefix(prefix): - if re.match(r'^[A-Za-z0-9_-]+$', prefix): # Allow alphanumeric characters, underscore, and hyphen + if re.match( + r"^[A-Za-z0-9_-]+$", prefix + ): # Allow alphanumeric characters, underscore, and hyphen return True else: return False + ################################################################# # Front-End Logic For Navigation Bar # ################################################################# + def main(): # Page Layout (Theme and Fonts have been established in .streamlit/config.toml) - st.set_page_config(layout='wide', initial_sidebar_state='collapsed') + st.set_page_config(layout="wide", initial_sidebar_state="collapsed") # Creating Navigation Bar @@ -104,7 +115,7 @@ def main(): icons=["house", "gear", "gear-fill", "book", "envelope"], menu_icon="cast", default_index=0, - orientation="horizontal" + orientation="horizontal", ) if selected == "Home": @@ -127,17 +138,18 @@ def main(): # lusSTR Home Page # ##################################################################### + def show_home_page(): image_path = importlib.resources.files("lusSTR") / "cli" / "logo.png" # CSS to hide full-screen button - hide_img_fs = ''' + hide_img_fs = """ - ''' + """ # Define column layout for centering image left_co, cent_co, last_co = st.columns([2.5, 8, 2.5]) @@ -147,14 +159,18 @@ def show_home_page(): # Apply CSS to hide full-screen button st.markdown(hide_img_fs, unsafe_allow_html=True) -# -- Welcome Message Stuff + # -- Welcome Message Stuff - st.markdown(""" + st.markdown( + """ lusSTR is an end-to-end workflow for processing human forensic data (STRs and SNPs) derived from Next Generation Sequencing (NGS) data for use in probabilistic genotyping software. For more information on lusSTR, visit our [GitHub page](https://github.com/bioforensics/lusSTR). - """, unsafe_allow_html=True) + """, + unsafe_allow_html=True, + ) + + st.info("Please Select One of the Tabs Above to Get Started on Processing Your Data!") - st.info('Please Select One of the Tabs Above to Get Started on Processing Your Data!') ##################################################################### # STR WORKFLOW # @@ -164,38 +180,45 @@ def show_home_page(): # Specify STR Settings Which Will Be Used to Generate Config File # ##################################################################### + def show_STR_page(): st.title("STR Workflow") - st.info('Please Select STR Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab.') + st.info( + "Please Select STR Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab." + ) # Input File Specification st.subheader("Input Files Selection") # Ask user if submitting a directory or individual file - st.info("Please Indicate If You Are Providing An Individual Input File or a Folder Containing Multiple Input Files") - input_option = st.radio("Select Input Option:", ("Individual File", "Folder with Multiple Files")) + st.info( + "Please Indicate If You Are Providing An Individual Input File or a Folder Containing Multiple Input Files" + ) + input_option = st.radio( + "Select Input Option:", ("Individual File", "Folder with Multiple Files") + ) # Initialize session state if not already initialized - if 'samp_input' not in st.session_state: + if "samp_input" not in st.session_state: st.session_state.samp_input = None # Logic for Path Picker based on user's input option if input_option == "Folder with Multiple Files": - st.write('Please select a folder:') - clicked = st.button('Folder Picker') + st.write("Please select a folder:") + clicked = st.button("Folder Picker") if clicked: dirname = folder_picker_dialog() - #st.text_input('You Selected The Following folder:', dirname) + # st.text_input('You Selected The Following folder:', dirname) st.session_state.samp_input = dirname else: - st.write('Please select a file:') - clicked_file = st.button('File Picker') + st.write("Please select a file:") + clicked_file = st.button("File Picker") if clicked_file: filename = file_picker_dialog() - #st.text_input('You Selected The Following file:', filename) + # st.text_input('You Selected The Following file:', filename) st.session_state.samp_input = filename # Display The Selected Path @@ -205,59 +228,115 @@ def show_STR_page(): # Store the Selected Path to Reference in Config samp_input = st.session_state.samp_input -##################################################################### -# STR: General Software Settings to Generate Config File # -##################################################################### + ##################################################################### + # STR: General Software Settings to Generate Config File # + ##################################################################### st.subheader("General Settings") col1, col2, col3, col4, col5 = st.columns(5) - analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor', 'GeneMarker HTS': 'genemarker'}[col1.selectbox("Analysis Software", options=["UAS", "STRait Razor v3", "GeneMarker HTS"], help="Indicate the analysis software used prior to lusSTR.")] - - sex = st.checkbox("Include X- and Y-STRs", help = "Check the box to include X- and Y-STRs, otherwise leave unchecked.") + analysis_software = { + "UAS": "uas", + "STRait Razor v3": "straitrazor", + "GeneMarker HTS": "genemarker", + }[ + col1.selectbox( + "Analysis Software", + options=["UAS", "STRait Razor v3", "GeneMarker HTS"], + help="Indicate the analysis software used prior to lusSTR.", + ) + ] + + sex = st.checkbox( + "Include X- and Y-STRs", + help="Check the box to include X- and Y-STRs, otherwise leave unchecked.", + ) - output = col2.text_input("Output File Name", "lusstr_output", help = "Please specify a name for the created files.") + output = col2.text_input( + "Output File Name", "lusstr_output", help="Please specify a name for the created files." + ) - kit = {'ForenSeq Signature Prep': 'forenseq', 'PowerSeq 46GY': 'powerseq'}[col3.selectbox("Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"])] + kit = {"ForenSeq Signature Prep": "forenseq", "PowerSeq 46GY": "powerseq"}[ + col3.selectbox( + "Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"] + ) + ] - nocombine = st.checkbox("Do Not Combine Identical Sequences", help = "If using STRait Razor data, by default, identical sequences (after removing flanking sequences) are combined and reads are summed. Checking this will not combine identical sequences.") + nocombine = st.checkbox( + "Do Not Combine Identical Sequences", + help="If using STRait Razor data, by default, identical sequences (after removing flanking sequences) are combined and reads are summed. Checking this will not combine identical sequences.", + ) -##################################################################### -# STP: Filter Settings to Generate Config File # -##################################################################### + ##################################################################### + # STP: Filter Settings to Generate Config File # + ##################################################################### st.subheader("Filter Settings") col1, col2, col3, col4, col5 = st.columns(5) - output_type = {'STRmix': 'strmix', 'EuroForMix': 'efm', 'MPSproto': 'mpsproto'}[col1.selectbox("Probabilistic Genotyping Software", options=["STRmix", "EuroForMix", "MPSproto"], help="Select which probabilistic genotyping software files to create")] - - profile_type = {'Evidence': 'evidence', 'Reference': 'reference'}[col2.selectbox("Profile Type", options=["Evidence", "Reference"], help="Select the file type (format) to create for the probabilistic genotyping software.")] - - data_type = {'Sequence': 'ngs', 'CE allele': 'ce', 'LUS+ allele': 'lusplus'}[col3.selectbox("Data Type", options=["Sequence", "CE allele", "LUS+ allele"], help="Select the allele type used to determine sequence type (belowAT, stutter or typed) and used in the final output file.")] - - info = st.checkbox("Create Allele Information File", value=True, help="Create file containing information about each sequence, including sequence type (belowAT, stutter or typed), stuttering sequence information and metrics involving stutter and noise.") + output_type = {"STRmix": "strmix", "EuroForMix": "efm", "MPSproto": "mpsproto"}[ + col1.selectbox( + "Probabilistic Genotyping Software", + options=["STRmix", "EuroForMix", "MPSproto"], + help="Select which probabilistic genotyping software files to create", + ) + ] + + profile_type = {"Evidence": "evidence", "Reference": "reference"}[ + col2.selectbox( + "Profile Type", + options=["Evidence", "Reference"], + help="Select the file type (format) to create for the probabilistic genotyping software.", + ) + ] + + data_type = {"Sequence": "ngs", "CE allele": "ce", "LUS+ allele": "lusplus"}[ + col3.selectbox( + "Data Type", + options=["Sequence", "CE allele", "LUS+ allele"], + help="Select the allele type used to determine sequence type (belowAT, stutter or typed) and used in the final output file.", + ) + ] + + info = st.checkbox( + "Create Allele Information File", + value=True, + help="Create file containing information about each sequence, including sequence type (belowAT, stutter or typed), stuttering sequence information and metrics involving stutter and noise.", + ) - separate = st.checkbox("Create Separate Files for Samples", help = "If checked, will create individual files for samples; If unchecked, will create one file with all samples.") + separate = st.checkbox( + "Create Separate Files for Samples", + help="If checked, will create individual files for samples; If unchecked, will create one file with all samples.", + ) - nofilters = st.checkbox("Skip all filtering steps", help = "Will not perform filtering; will still create EFM/MPSproto/STRmix output files") + nofilters = st.checkbox( + "Skip all filtering steps", + help="Will not perform filtering; will still create EFM/MPSproto/STRmix output files", + ) - strand = {'UAS': 'uas', 'Forward Strand': 'forward'}[col4.selectbox("Strand Orientation", options=["UAS", "Forward Strand"], help="Indicates the strand orientation in which to report the sequence in the final output table; for STRmix NGS only.")] + strand = {"UAS": "uas", "Forward Strand": "forward"}[ + col4.selectbox( + "Strand Orientation", + options=["UAS", "Forward Strand"], + help="Indicates the strand orientation in which to report the sequence in the final output table; for STRmix NGS only.", + ) + ] -##################################################################### -# STR: Specify Working Directory # -##################################################################### + ##################################################################### + # STR: Specify Working Directory # + ##################################################################### st.subheader("Set Output Folder") col1, col2, col3, col4, col5 = st.columns(5) # Initialize session state if not already initialized - if 'wd_dirname' not in st.session_state: + if "wd_dirname" not in st.session_state: st.session_state.wd_dirname = None - clicked_wd = col1.button('Please Select An Output Folder') + clicked_wd = col1.button("Please Select An Output Folder") if clicked_wd: wd = folder_picker_dialog() st.session_state.wd_dirname = wd @@ -269,9 +348,9 @@ def show_STR_page(): # Store Selected Path to Reference in Config wd_dirname = st.session_state.wd_dirname -##################################################################### -# STR: Generate Config File Based on Settings # -##################################################################### + ##################################################################### + # STR: Generate Config File Based on Settings # + ##################################################################### # Submit Button Instance if st.button("Submit"): @@ -281,7 +360,9 @@ def show_STR_page(): # Validate output prefix if not validate_prefix(output): - st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") + st.warning( + "Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed." + ) st.stop() # Stop execution if prefix is invalid # Display loading spinner (Continuing Process Checks Above Were Passed) @@ -302,7 +383,7 @@ def show_STR_page(): "info": info, "separate": separate, "nofilters": nofilters, - "strand": strand + "strand": strand, } # Generate YAML config file @@ -318,13 +399,20 @@ def show_STR_page(): # Run lusSTR command in terminal try: subprocess.run(command, check=True) - st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") + st.success( + "Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix" + ) except subprocess.CalledProcessError as e: st.error(f"Error: {e}") - st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") + st.info( + "Please make sure to check the 'How to Use' tab for common error resolutions." + ) else: - st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") + st.warning( + "Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting." + ) + ##################################################################### # SNP WORKFLOW # @@ -334,38 +422,45 @@ def show_STR_page(): # Specify SNP Settings Which Will Be Used to Generate Config File # ##################################################################### + def show_SNP_page(): st.title("SNP Workflow") - st.info('Please Select SNP Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab.') + st.info( + "Please Select SNP Settings Below for lusSTR! For Information Regarding the Settings, See the How to Use Tab." + ) # Input File Specification st.subheader("Input Files Selection") # Ask user if submitting a directory or individual file - st.info("Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files") - input_option = st.radio("Select Input Option:", ("Individual File", "Directory with Multiple Files")) + st.info( + "Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files" + ) + input_option = st.radio( + "Select Input Option:", ("Individual File", "Directory with Multiple Files") + ) # Initialize session state if not already initialized - if 'samp_input' not in st.session_state: + if "samp_input" not in st.session_state: st.session_state.samp_input = None # Logic for Path Picker based on user's input option if input_option == "Directory with Multiple Files": - st.write('Please select a folder:') - clicked = st.button('Folder Picker') + st.write("Please select a folder:") + clicked = st.button("Folder Picker") if clicked: dirname = folder_picker_dialog() - #st.text_input('You Selected The Following folder:', dirname) + # st.text_input('You Selected The Following folder:', dirname) st.session_state.samp_input = dirname else: - st.write('Please select a file:') - clicked_file = st.button('File Picker') + st.write("Please select a file:") + clicked_file = st.button("File Picker") if clicked_file: filename = file_picker_dialog() - #st.text_input('You Selected The Following file:', filename) + # st.text_input('You Selected The Following file:', filename) st.session_state.samp_input = filename # Display The Selected Path @@ -375,58 +470,98 @@ def show_SNP_page(): # Store Selected Path to Reference in Config samp_input = st.session_state.samp_input -##################################################################### -# SNP: General Software Settings to Generate Config File # -##################################################################### + ##################################################################### + # SNP: General Software Settings to Generate Config File # + ##################################################################### st.subheader("General Software") - analysis_software = {'UAS': 'uas', 'STRait Razor v3': 'straitrazor'}[st.selectbox("Analysis Software", options=["UAS", "STRait Razor v3"], help="Indicate the analysis software used prior to lusSTR sex.")] - - output = st.text_input("Please Specify a prefix for generated output files or leave as default", "lusstr_output", help = "Be sure to see requirements in How to Use tab.") + analysis_software = {"UAS": "uas", "STRait Razor v3": "straitrazor"}[ + st.selectbox( + "Analysis Software", + options=["UAS", "STRait Razor v3"], + help="Indicate the analysis software used prior to lusSTR sex.", + ) + ] + + output = st.text_input( + "Please Specify a prefix for generated output files or leave as default", + "lusstr_output", + help="Be sure to see requirements in How to Use tab.", + ) - kit = {'Signature Prep': 'sigprep', 'Kintelligence': 'kintelligence'}[st.selectbox("Library Preparation Kit", options=["Signature Prep", "Kintelligence"])] + kit = {"Signature Prep": "sigprep", "Kintelligence": "kintelligence"}[ + st.selectbox("Library Preparation Kit", options=["Signature Prep", "Kintelligence"]) + ] -##################################################################### -# SNP: Format Settings to Generate Config File # -##################################################################### + ##################################################################### + # SNP: Format Settings to Generate Config File # + ##################################################################### st.subheader("Format Settings") # -- Select Type (Unique to SNP Workflow) - types_mapping = {"Identify SNPs Only": "i", "Phenotype Only": "p", "Ancestry Only": "a", "All": "all"} - selected_types = st.multiselect("Select Types:", options=types_mapping.keys(), help="Please Select a Choice or any Combination") - types_string = "all" if "All" in selected_types else ", ".join(types_mapping.get(t, t) for t in selected_types) + types_mapping = { + "Identify SNPs Only": "i", + "Phenotype Only": "p", + "Ancestry Only": "a", + "All": "all", + } + selected_types = st.multiselect( + "Select Types:", + options=types_mapping.keys(), + help="Please Select a Choice or any Combination", + ) + types_string = ( + "all" + if "All" in selected_types + else ", ".join(types_mapping.get(t, t) for t in selected_types) + ) - #if selected_types: + # if selected_types: # st.text_input("You Selected:", types_string) # -- Filter - nofilters = st.checkbox("Skip all filtering steps", help = "If no filtering is desired at the format step; if False, will remove any allele designated as Not Typed") + nofilters = st.checkbox( + "Skip all filtering steps", + help="If no filtering is desired at the format step; if False, will remove any allele designated as Not Typed", + ) -##################################################################### -# SNP: Convert Settings to Generate Config File # -##################################################################### + ##################################################################### + # SNP: Convert Settings to Generate Config File # + ##################################################################### st.subheader("Convert Settings") - separate = st.checkbox("Create Separate Files for Samples", help = "If want to separate samples into individual files for use in EFM") + separate = st.checkbox( + "Create Separate Files for Samples", + help="If want to separate samples into individual files for use in EFM", + ) - strand = {'UAS': 'uas', 'Forward': 'forward'}[st.selectbox("Strand Orientation", options=["UAS", "Forward"], help="Indicate which orientation to report the alleles for the SigPrep SNPs.")] + strand = {"UAS": "uas", "Forward": "forward"}[ + st.selectbox( + "Strand Orientation", + options=["UAS", "Forward"], + help="Indicate which orientation to report the alleles for the SigPrep SNPs.", + ) + ] # Analytical threshold value - thresh = st.number_input("Analytical threshold value:", value=0.03, step=0.01, min_value = 0.0) + thresh = st.number_input("Analytical threshold value:", value=0.03, step=0.01, min_value=0.0) -##################################################################### -# SNP: Specify a Reference File if User Has One # -##################################################################### + ##################################################################### + # SNP: Specify a Reference File if User Has One # + ##################################################################### st.subheader("Specify a Reference File (Optional)") - if 'reference' not in st.session_state: + if "reference" not in st.session_state: st.session_state.reference = None - clicked_ref = st.button('Please Specify Your Reference File If You Have One', help = "List IDs of the samples to be run as references in EFM; default is no reference samples") + clicked_ref = st.button( + "Please Specify Your Reference File If You Have One", + help="List IDs of the samples to be run as references in EFM; default is no reference samples", + ) if clicked_ref: ref = file_picker_dialog() st.session_state.reference = ref @@ -438,17 +573,19 @@ def show_SNP_page(): # Store Selected Path to Reference in Config reference = st.session_state.reference -##################################################################### -# SNP: Specify Working Directory # -##################################################################### + ##################################################################### + # SNP: Specify Working Directory # + ##################################################################### st.subheader("Set Working Directory") # Initialize session state if not already initialized - if 'wd_dirname' not in st.session_state: + if "wd_dirname" not in st.session_state: st.session_state.wd_dirname = None - clicked_wd = st.button('Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved') + clicked_wd = st.button( + "Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved" + ) if clicked_wd: wd = folder_picker_dialog() st.session_state.wd_dirname = wd @@ -460,9 +597,9 @@ def show_SNP_page(): # Store Selected Path to Reference in Config wd_dirname = st.session_state.wd_dirname -##################################################################### -# SNP: Generate Config File Based on Settings # -##################################################################### + ##################################################################### + # SNP: Generate Config File Based on Settings # + ##################################################################### # Submit Button Instance if st.button("Submit"): @@ -472,7 +609,9 @@ def show_SNP_page(): # Validate output prefix if not validate_prefix(output): - st.warning("Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed.") + st.warning( + "Please enter a valid output prefix. Only alphanumeric characters, underscore, and hyphen are allowed." + ) st.stop() # Stop execution if prefix is invalid # Display loading spinner (Continuing Process Checks Above Were Passed) @@ -490,7 +629,7 @@ def show_SNP_page(): "separate": separate, "nofilter": nofilters, "strand": strand, - "references": None # Default value is None + "references": None, # Default value is None } # If a reference file was specified, add to config @@ -510,41 +649,60 @@ def show_SNP_page(): # Run lusSTR command in terminal try: subprocess.run(command, check=True) - st.success("Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix") + st.success( + "Config File Generated and lusSTR Executed Successfully! Output Files Have Been Saved to Your Designated Directory and Labeled with your Specified Prefix" + ) except subprocess.CalledProcessError as e: st.error(f"Error: {e}") - st.info("Please make sure to check the 'How to Use' tab for common error resolutions.") + st.info( + "Please make sure to check the 'How to Use' tab for common error resolutions." + ) else: - st.warning("Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting.") + st.warning( + "Please make sure to fill out all required fields (Analysis Software, Input Directory or File, Prefix for Output, and Specification of Working Directory) before submitting." + ) + ##################################################################### # How To Use Page # ##################################################################### + def show_how_to_use_page(): st.title("Common Errors and Best Practices for Using lusSTR") st.header("1. File/Folder Path Formatting") - st.write("Please ensure that the displayed path accurately reflects your selection. When using the file or folder picker, navigate to the desired location and click 'OK' to confirm your selection.") + st.write( + "Please ensure that the displayed path accurately reflects your selection. When using the file or folder picker, navigate to the desired location and click 'OK' to confirm your selection." + ) st.header("2. Specifying Output Prefix") - st.write("The purpose of specifying the output prefix is for lusSTR to create result files and folders with that prefix in your working directory. Please ensure that you are following proper file naming formatting and rules when specifying this prefix. Avoid using characters such as '/', '', '.', and others. Note: To avoid potential errors, you can simply use the default placeholder for output.") + st.write( + "The purpose of specifying the output prefix is for lusSTR to create result files and folders with that prefix in your working directory. Please ensure that you are following proper file naming formatting and rules when specifying this prefix. Avoid using characters such as '/', '', '.', and others. Note: To avoid potential errors, you can simply use the default placeholder for output." + ) - st.code("Incorrect: 'working_directory/subfolder/subfolder'\nCorrect: working_directory/output # or just output, since you will likely already be in the working directory when specifying it before submitting.") + st.code( + "Incorrect: 'working_directory/subfolder/subfolder'\nCorrect: working_directory/output # or just output, since you will likely already be in the working directory when specifying it before submitting." + ) - st.write("Note that some result files may be saved directly in the working directory with the specified prefix, while others will be populated in a folder labeled with the prefix in your working directory.") + st.write( + "Note that some result files may be saved directly in the working directory with the specified prefix, while others will be populated in a folder labeled with the prefix in your working directory." + ) st.write("Be aware of this behavior when checking for output files.") st.header("3. Specifying Working Directory") - st.write("Please Ensure That You Properly Specify a Working Directory. This is where all lusSTR output files will be saved. To avoid potential errors, specifying a working directory is required.") + st.write( + "Please Ensure That You Properly Specify a Working Directory. This is where all lusSTR output files will be saved. To avoid potential errors, specifying a working directory is required." + ) st.title("About lusSTR") - st.markdown(""" + st.markdown( + """ **_lusSTR Accommodates Four Different Input Formats:_** @@ -557,15 +715,22 @@ def show_how_to_use_page(): (4) Sample(s) sequences in CSV format; first four columns must be Locus, NumReads, Sequence, SampleID; Optional last two columns can be Project and Analysis IDs. - """, unsafe_allow_html = True) + """, + unsafe_allow_html=True, + ) + ##################################################################### # Contact Page # ##################################################################### + def show_contact_page(): st.title("Contact Us") - st.write("For any questions or issues, please contact rebecca.mitchell@st.dhs.gov, daniel.standage@st.dhs.gov, or s.h.syed@email.msmary.edu") + st.write( + "For any questions or issues, please contact rebecca.mitchell@st.dhs.gov, daniel.standage@st.dhs.gov, or s.h.syed@email.msmary.edu" + ) + ##################################################################### # lusSTR RUN # @@ -574,6 +739,7 @@ def show_contact_page(): if __name__ == "__main__": main() + def subparser(subparsers): parser = subparsers.add_parser("gui", help="Launch the Streamlit GUI") parser.set_defaults(func=main) From edad9eacf7ed7c0f49786f447e1c0451d9aa0ad7 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 10:29:48 -0400 Subject: [PATCH 06/17] updating GUI [skip ci] --- Makefile | 2 +- lusSTR/cli/gui.py | 93 ++++++++++++++++++++--------------------------- 2 files changed, 40 insertions(+), 55 deletions(-) diff --git a/Makefile b/Makefile index 77d5cc7a..3a977559 100755 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ style: ## format: auto-reformat code with Black format: - black --line-length=99 *.py lusSTR/scripts/*.py lusSTR/wrappers/*.py lusSTR/tests/test_*.py + black --line-length=99 *.py lusSTR/cli/gui.py lusSTR/scripts/*.py lusSTR/wrappers/*.py lusSTR/tests/test_*.py ## devenv: configure a development environment devenv: diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 1047976c..2e7db3ad 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -269,7 +269,7 @@ def show_STR_page(): ) ##################################################################### - # STP: Filter Settings to Generate Config File # + # STR: Filter Settings to Generate Config File # ##################################################################### st.subheader("Filter Settings") @@ -316,10 +316,10 @@ def show_STR_page(): help="Will not perform filtering; will still create EFM/MPSproto/STRmix output files", ) - strand = {"UAS": "uas", "Forward Strand": "forward"}[ + strand = {"UAS Orientation": "uas", "Forward Strand": "forward"}[ col4.selectbox( "Strand Orientation", - options=["UAS", "Forward Strand"], + options=["UAS Orientation", "Forward Strand"], help="Indicates the strand orientation in which to report the sequence in the final output table; for STRmix NGS only.", ) ] @@ -435,10 +435,10 @@ def show_SNP_page(): # Ask user if submitting a directory or individual file st.info( - "Please Indicate If You Are Providing An Individual Input File or a Directory Containing Multiple Input Files" + "Please Indicate If You Are Providing An Individual Input File or a Folder Containing Multiple Input Files" ) input_option = st.radio( - "Select Input Option:", ("Individual File", "Directory with Multiple Files") + "Select Input Option:", ("Individual File", "Folder with Multiple Files") ) # Initialize session state if not already initialized @@ -447,7 +447,7 @@ def show_SNP_page(): # Logic for Path Picker based on user's input option - if input_option == "Directory with Multiple Files": + if input_option == "Folder with Multiple Files": st.write("Please select a folder:") clicked = st.button("Folder Picker") if clicked: @@ -474,43 +474,45 @@ def show_SNP_page(): # SNP: General Software Settings to Generate Config File # ##################################################################### - st.subheader("General Software") + st.subheader("General Settings") + + col1, col2, col3, col4, col5 = st.columns(5) analysis_software = {"UAS": "uas", "STRait Razor v3": "straitrazor"}[ - st.selectbox( + col1.selectbox( "Analysis Software", options=["UAS", "STRait Razor v3"], help="Indicate the analysis software used prior to lusSTR sex.", ) ] - output = st.text_input( - "Please Specify a prefix for generated output files or leave as default", - "lusstr_output", - help="Be sure to see requirements in How to Use tab.", + output = col2.text_input( + "Output File Name", "lusstr_output", help="Please specify a name for the created files." ) kit = {"Signature Prep": "sigprep", "Kintelligence": "kintelligence"}[ - st.selectbox("Library Preparation Kit", options=["Signature Prep", "Kintelligence"]) + col3.selectbox("Library Preparation Kit", options=["Signature Prep", "Kintelligence"]) ] ##################################################################### # SNP: Format Settings to Generate Config File # ##################################################################### - st.subheader("Format Settings") + st.subheader("Convert Settings") + + col1, col2, col3, col4, col5 = st.columns(5) # -- Select Type (Unique to SNP Workflow) types_mapping = { - "Identify SNPs Only": "i", - "Phenotype Only": "p", - "Ancestry Only": "a", - "All": "all", + "Identify SNPs": "i", + "Phenotype SNPs": "p", + "Ancestry SNPs": "a", + "All SNPs": "all", } - selected_types = st.multiselect( - "Select Types:", + selected_types = col1.multiselect( + "Select SNP Types:", options=types_mapping.keys(), - help="Please Select a Choice or any Combination", + help="Select the SNP types to process; can select one or more options", ) types_string = ( "all" @@ -518,81 +520,66 @@ def show_SNP_page(): else ", ".join(types_mapping.get(t, t) for t in selected_types) ) - # if selected_types: - # st.text_input("You Selected:", types_string) - # -- Filter nofilters = st.checkbox( "Skip all filtering steps", - help="If no filtering is desired at the format step; if False, will remove any allele designated as Not Typed", + help="Specify for no filtering", ) ##################################################################### # SNP: Convert Settings to Generate Config File # ##################################################################### - st.subheader("Convert Settings") - separate = st.checkbox( "Create Separate Files for Samples", help="If want to separate samples into individual files for use in EFM", ) - strand = {"UAS": "uas", "Forward": "forward"}[ - st.selectbox( + strand = {"UAS Orientation": "uas", "Forward Strand": "forward"}[ + col2.selectbox( "Strand Orientation", - options=["UAS", "Forward"], + options=["UAS Orientation", "Forward Strand"], help="Indicate which orientation to report the alleles for the SigPrep SNPs.", ) ] # Analytical threshold value - thresh = st.number_input("Analytical threshold value:", value=0.03, step=0.01, min_value=0.0) + thresh = col3.number_input("Analytical threshold value:", value=0.03, step=0.01, min_value=0.0) ##################################################################### # SNP: Specify a Reference File if User Has One # ##################################################################### - st.subheader("Specify a Reference File (Optional)") + col1, col2, col3 = st.columns(3) if "reference" not in st.session_state: st.session_state.reference = None - clicked_ref = st.button( - "Please Specify Your Reference File If You Have One", + reference = col1.text_input( + "Please Specify Your Reference Sample IDs", help="List IDs of the samples to be run as references in EFM; default is no reference samples", ) - if clicked_ref: - ref = file_picker_dialog() - st.session_state.reference = ref - - # Display Path to Selected Reference File - if st.session_state.reference: - st.text_input("Your Specified Reference File:", st.session_state.reference) - - # Store Selected Path to Reference in Config - reference = st.session_state.reference ##################################################################### # SNP: Specify Working Directory # ##################################################################### - st.subheader("Set Working Directory") + st.subheader("Set Output Folder") + + col1, col2, col3, col4, col5 = st.columns(5) # Initialize session state if not already initialized if "wd_dirname" not in st.session_state: st.session_state.wd_dirname = None - clicked_wd = st.button( - "Please Specify A Working Directory Where You Would Like For All Output Results To Be Saved" - ) + clicked_wd = col1.button("Please Select An Output Folder") if clicked_wd: wd = folder_picker_dialog() st.session_state.wd_dirname = wd # Display selected path if st.session_state.wd_dirname: - st.text_input("Your Specified Working Directory:", st.session_state.wd_dirname) + st.text_input("Your Specified Output Folder:", st.session_state.wd_dirname) # Store Selected Path to Reference in Config wd_dirname = st.session_state.wd_dirname @@ -685,18 +672,16 @@ def show_how_to_use_page(): "The purpose of specifying the output prefix is for lusSTR to create result files and folders with that prefix in your working directory. Please ensure that you are following proper file naming formatting and rules when specifying this prefix. Avoid using characters such as '/', '', '.', and others. Note: To avoid potential errors, you can simply use the default placeholder for output." ) - st.code( - "Incorrect: 'working_directory/subfolder/subfolder'\nCorrect: working_directory/output # or just output, since you will likely already be in the working directory when specifying it before submitting." - ) + st.code("Incorrect: 'working_directory/subfolder/subfolder'\nCorrect: output") st.write( "Note that some result files may be saved directly in the working directory with the specified prefix, while others will be populated in a folder labeled with the prefix in your working directory." ) st.write("Be aware of this behavior when checking for output files.") - st.header("3. Specifying Working Directory") + st.header("3. Specifying Output Folder") st.write( - "Please Ensure That You Properly Specify a Working Directory. This is where all lusSTR output files will be saved. To avoid potential errors, specifying a working directory is required." + "Please Ensure That You Properly Specify an Output Folder. This is where all lusSTR output files will be saved. To avoid potential errors, specifying a working directory is required." ) st.title("About lusSTR") From 05a9d7e0988e9de13edeae97e056ab1c06df58c4 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 10:41:10 -0400 Subject: [PATCH 07/17] updated README [skip ci] --- README.md | 15 +++++++++++++++ lusSTR/cli/gui.py | 15 +++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8e5b4d78..a05fd1d9 100755 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ lusSTR is a tool written in Python to convert NGS sequence data of forensic STR Further, lusSTR can perform filtering and stutter identification using the CE allele, the LUS+ allele, or the bracketed sequence form for autosomal loci and create files for direct input into three probabilistic genotyping software packages, EuroForMix (CE and LUS+), MPSproto (NGS), and STRmix (CE and NGS). lusSTR also processes SNP data from the Verogen ForenSeq and Kintelligence panels and create evidence and/or reference files for use in EFM. See the below section ```SNP Data Processing``` for more information. +____ + +**lusSTR is available as a command line tool or as a GUI.** +____ This Python package has been written for use with either: * ForenSeq Signature Prep panel @@ -42,6 +46,17 @@ make devenv ## Usage +## *GUI* + +Once lusSTR has been installed, the GUI can be started with the command: +``` +lusstr gui +``` +All lusSTR settings for either the STR pipeline or the SNP pipeline can be specified through the GUI. +____ + +## *Command line interface* + lusSTR accomodates four different input formats: (1) UAS Sample Details Report, UAS Sample Report, and UAS Phenotype Report (for SNP processing) in .xlsx format (a single file or directory containing multiple files) (2) STRait Razor v3 output with one sample per file (a single file or directory containing multiple files) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 2e7db3ad..8c46b988 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -210,7 +210,6 @@ def show_STR_page(): clicked = st.button("Folder Picker") if clicked: dirname = folder_picker_dialog() - # st.text_input('You Selected The Following folder:', dirname) st.session_state.samp_input = dirname else: @@ -218,7 +217,6 @@ def show_STR_page(): clicked_file = st.button("File Picker") if clicked_file: filename = file_picker_dialog() - # st.text_input('You Selected The Following file:', filename) st.session_state.samp_input = filename # Display The Selected Path @@ -253,16 +251,17 @@ def show_STR_page(): help="Check the box to include X- and Y-STRs, otherwise leave unchecked.", ) - output = col2.text_input( - "Output File Name", "lusstr_output", help="Please specify a name for the created files." - ) - kit = {"ForenSeq Signature Prep": "forenseq", "PowerSeq 46GY": "powerseq"}[ - col3.selectbox( - "Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"] + col2.selectbox( + "Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"], help="Specify the library preparation kit used to generate the sequences." ) ] + output = col3.text_input( + "Output File Name", "lusstr_output", help="Please specify a name for the created files. It can only contain alphanumeric characters, underscores and hyphens. No spaces allowed." + ) + + nocombine = st.checkbox( "Do Not Combine Identical Sequences", help="If using STRait Razor data, by default, identical sequences (after removing flanking sequences) are combined and reads are summed. Checking this will not combine identical sequences.", From 0977abdab5d0419d2940b4c8f66c696d6b4495b7 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 11:18:42 -0400 Subject: [PATCH 08/17] updated README and updated GUI [skip ci] --- README.md | 2 +- lusSTR/cli/gui.py | 20 ++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a05fd1d9..36d217cd 100755 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Once lusSTR has been installed, the GUI can be started with the command: ``` lusstr gui ``` -All lusSTR settings for either the STR pipeline or the SNP pipeline can be specified through the GUI. +All lusSTR settings for either the STR pipeline or the SNP pipeline can be specified after selecting the desired pipeline tab. ____ ## *Command line interface* diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 8c46b988..87c14ac7 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -111,7 +111,7 @@ def main(): selected = option_menu( menu_title=None, - options=["Home", "STR", "SNP", "How to Use", "Contact"], + options=["Home", "STRs", "SNPs", "How to Use", "Contact"], icons=["house", "gear", "gear-fill", "book", "envelope"], menu_icon="cast", default_index=0, @@ -121,10 +121,10 @@ def main(): if selected == "Home": show_home_page() - elif selected == "STR": + elif selected == "STRs": show_STR_page() - elif selected == "SNP": + elif selected == "SNPs": show_SNP_page() elif selected == "How to Use": @@ -206,15 +206,13 @@ def show_STR_page(): # Logic for Path Picker based on user's input option if input_option == "Folder with Multiple Files": - st.write("Please select a folder:") - clicked = st.button("Folder Picker") + clicked = st.button("Please Select a Folder") if clicked: dirname = folder_picker_dialog() st.session_state.samp_input = dirname else: - st.write("Please select a file:") - clicked_file = st.button("File Picker") + clicked_file = st.button("Please Select a File") if clicked_file: filename = file_picker_dialog() st.session_state.samp_input = filename @@ -327,7 +325,7 @@ def show_STR_page(): # STR: Specify Working Directory # ##################################################################### - st.subheader("Set Output Folder") + st.subheader("Output Folder Selection") col1, col2, col3, col4, col5 = st.columns(5) @@ -447,16 +445,14 @@ def show_SNP_page(): # Logic for Path Picker based on user's input option if input_option == "Folder with Multiple Files": - st.write("Please select a folder:") - clicked = st.button("Folder Picker") + clicked = st.button("Please Select a Folder") if clicked: dirname = folder_picker_dialog() # st.text_input('You Selected The Following folder:', dirname) st.session_state.samp_input = dirname else: - st.write("Please select a file:") - clicked_file = st.button("File Picker") + clicked_file = st.button("Please Select a File") if clicked_file: filename = file_picker_dialog() # st.text_input('You Selected The Following file:', filename) From e90309cca53f6b2b454e0ea27499964c77ae7f33 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 11:28:40 -0400 Subject: [PATCH 09/17] changed GUI text --- lusSTR/cli/gui.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 87c14ac7..83cedb9f 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -251,15 +251,18 @@ def show_STR_page(): kit = {"ForenSeq Signature Prep": "forenseq", "PowerSeq 46GY": "powerseq"}[ col2.selectbox( - "Library Preparation Kit", options=["ForenSeq Signature Prep", "PowerSeq 46GY"], help="Specify the library preparation kit used to generate the sequences." + "Library Preparation Kit", + options=["ForenSeq Signature Prep", "PowerSeq 46GY"], + help="Specify the library preparation kit used to generate the sequences.", ) ] output = col3.text_input( - "Output File Name", "lusstr_output", help="Please specify a name for the created files. It can only contain alphanumeric characters, underscores and hyphens. No spaces allowed." + "Output File Name", + "lusstr_output", + help="Please specify a name for the created files. It can only contain alphanumeric characters, underscores and hyphens. No spaces allowed.", ) - nocombine = st.checkbox( "Do Not Combine Identical Sequences", help="If using STRait Razor data, by default, identical sequences (after removing flanking sequences) are combined and reads are summed. Checking this will not combine identical sequences.", @@ -309,15 +312,15 @@ def show_STR_page(): ) nofilters = st.checkbox( - "Skip all filtering steps", - help="Will not perform filtering; will still create EFM/MPSproto/STRmix output files", + "Skip All Filtering Steps", + help="Filtering will not be performed but will still create EFM/MPSproto/STRmix output files containing all sequences.", ) strand = {"UAS Orientation": "uas", "Forward Strand": "forward"}[ col4.selectbox( "Strand Orientation", - options=["UAS Orientation", "Forward Strand"], - help="Indicates the strand orientation in which to report the sequence in the final output table; for STRmix NGS only.", + options=["Forward Strand", "UAS Orientation"], + help="Indicates the strand orientation in which to report the sequence in the final output table as some markers are reported in the UAS on the reverse strand. Selecting the UAS Orientation will report those markers on the reverse strand while the remaining will be reported on the forward strand. Selecting the Forward Strand will report all markers on the forward strand orientation. This applies to STRmix NGS only.", ) ] From 26d3c3f2787f4e5cb3b8ccb0d3738a3e7adb3d8a Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 11:46:52 -0400 Subject: [PATCH 10/17] troubleshooting CI issues --- lusSTR/cli/gui.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 83cedb9f..7fa79aea 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -31,6 +31,10 @@ root = tk.Tk() root.withdraw() # Hide the root window +if os.environ.get('DISPLAY','') == '': + print('no display found. Using :0.0') + os.environ.__setitem__('DISPLAY', ':0.0') + ################################################################# # Functions # ################################################################# From d69ba462f53a36852a063113a14d0608276c1f82 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 11:51:01 -0400 Subject: [PATCH 11/17] troubleshooting CI issues again --- lusSTR/cli/gui.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 7fa79aea..3c8e29b0 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -27,14 +27,15 @@ import tkinter as tk from tkinter import filedialog -# Create a global Tkinter root window -root = tk.Tk() -root.withdraw() # Hide the root window if os.environ.get('DISPLAY','') == '': print('no display found. Using :0.0') os.environ.__setitem__('DISPLAY', ':0.0') +# Create a global Tkinter root window +root = tk.Tk() +root.withdraw() # Hide the root window + ################################################################# # Functions # ################################################################# From 0a2c218f897a0e9724b87c50f1864dbe6aa2d8b2 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 11:55:50 -0400 Subject: [PATCH 12/17] troubleshooting CI issues again again --- lusSTR/cli/gui.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 3c8e29b0..a4029496 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -29,8 +29,7 @@ if os.environ.get('DISPLAY','') == '': - print('no display found. Using :0.0') - os.environ.__setitem__('DISPLAY', ':0.0') + os.environ.__setitem__('DISPLAY', ':1.0') # Create a global Tkinter root window root = tk.Tk() From a12cfb0d57a8ffbceb55c647583733fd736c5908 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 13:30:08 -0400 Subject: [PATCH 13/17] troubleshoot again again again --- lusSTR/cli/gui.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index a4029496..d6a7439d 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -28,12 +28,13 @@ from tkinter import filedialog -if os.environ.get('DISPLAY','') == '': - os.environ.__setitem__('DISPLAY', ':1.0') # Create a global Tkinter root window -root = tk.Tk() -root.withdraw() # Hide the root window +try: + root = tk.Tk() + root.withdraw() # Hide the root window +except _tkinter.TclError: + print("No GUI available!") ################################################################# # Functions # From 6f87808a853a2a612dd8392c619d67a6e2708fd4 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 13:34:10 -0400 Subject: [PATCH 14/17] troubleshoot again one last time --- lusSTR/cli/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index d6a7439d..21abaac0 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -33,7 +33,7 @@ try: root = tk.Tk() root.withdraw() # Hide the root window -except _tkinter.TclError: +except: print("No GUI available!") ################################################################# From 7c9381875c5ac7fac4e0bbf7436551f9d7a0cdb6 Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Tue, 2 Jul 2024 13:37:46 -0400 Subject: [PATCH 15/17] pinning numpy version --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index ae8d0828..f7e873d3 100755 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ "snakemake>=7.22,<8.0", "pyyaml>=6.0", "matplotlib>=3.8", + "numpy==1.26.4", "streamlit>=1.31.0", "streamlit_option_menu>=0.3.12", ], From d1efb70375cf67ef5f9f6a90138541f3b1426912 Mon Sep 17 00:00:00 2001 From: Daniel Standage Date: Mon, 8 Jul 2024 11:54:22 -0400 Subject: [PATCH 16/17] Try removing tk from gui.py --- lusSTR/cli/gui.py | 13 ------------- lusSTR/scripts/file_selector.py | 1 - lusSTR/scripts/folder_selector.py | 1 - lusSTR/wrappers/snps_convert.py | 8 ++------ 4 files changed, 2 insertions(+), 21 deletions(-) diff --git a/lusSTR/cli/gui.py b/lusSTR/cli/gui.py index 21abaac0..bcd4d823 100644 --- a/lusSTR/cli/gui.py +++ b/lusSTR/cli/gui.py @@ -22,19 +22,6 @@ import os import re -# ------ Packages For File/Folder Directory Selection --------- # - -import tkinter as tk -from tkinter import filedialog - - - -# Create a global Tkinter root window -try: - root = tk.Tk() - root.withdraw() # Hide the root window -except: - print("No GUI available!") ################################################################# # Functions # diff --git a/lusSTR/scripts/file_selector.py b/lusSTR/scripts/file_selector.py index beff54de..2bb858ac 100644 --- a/lusSTR/scripts/file_selector.py +++ b/lusSTR/scripts/file_selector.py @@ -12,7 +12,6 @@ import tkinter as tk from tkinter import filedialog -import sys import json diff --git a/lusSTR/scripts/folder_selector.py b/lusSTR/scripts/folder_selector.py index fc554234..80aa85dd 100644 --- a/lusSTR/scripts/folder_selector.py +++ b/lusSTR/scripts/folder_selector.py @@ -12,7 +12,6 @@ import tkinter as tk from tkinter import filedialog -import sys import json diff --git a/lusSTR/wrappers/snps_convert.py b/lusSTR/wrappers/snps_convert.py index ad732760..dbbf4f5d 100644 --- a/lusSTR/wrappers/snps_convert.py +++ b/lusSTR/wrappers/snps_convert.py @@ -66,13 +66,9 @@ def bin_snps(sample_file, output_type, sample): start = snp_num * 1000 if snp_num != 9: end = start + 1000 - bin_df = sorted_file.iloc[ - start:end, - ].reset_index(drop=True) + bin_df = sorted_file.iloc[start:end,].reset_index(drop=True) else: - bin_df = sorted_file.iloc[ - start : len(sorted_file), - ].reset_index(drop=True) + bin_df = sorted_file.iloc[start : len(sorted_file),].reset_index(drop=True) bin_df["Sample.Name"] = bin_df["Sample.Name"] + "_set" + str((snp_num + 1)) compiled_table = pd.concat([compiled_table, bin_df]) bin_df.to_csv( From e7d5cb710c35f9e5459cedf9c04e21e1854725dd Mon Sep 17 00:00:00 2001 From: rnmitchell Date: Mon, 8 Jul 2024 12:01:38 -0400 Subject: [PATCH 17/17] formatted file --- lusSTR/wrappers/snps_convert.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lusSTR/wrappers/snps_convert.py b/lusSTR/wrappers/snps_convert.py index dbbf4f5d..ad732760 100644 --- a/lusSTR/wrappers/snps_convert.py +++ b/lusSTR/wrappers/snps_convert.py @@ -66,9 +66,13 @@ def bin_snps(sample_file, output_type, sample): start = snp_num * 1000 if snp_num != 9: end = start + 1000 - bin_df = sorted_file.iloc[start:end,].reset_index(drop=True) + bin_df = sorted_file.iloc[ + start:end, + ].reset_index(drop=True) else: - bin_df = sorted_file.iloc[start : len(sorted_file),].reset_index(drop=True) + bin_df = sorted_file.iloc[ + start : len(sorted_file), + ].reset_index(drop=True) bin_df["Sample.Name"] = bin_df["Sample.Name"] + "_set" + str((snp_num + 1)) compiled_table = pd.concat([compiled_table, bin_df]) bin_df.to_csv(