From d4c69149178989cc5fa9f036553e052d2e14b203 Mon Sep 17 00:00:00 2001 From: Will Holtz Date: Fri, 22 Jul 2022 15:57:25 -0700 Subject: [PATCH] Add configuration file with workflow definitions (#638) "RT Prediction" is now "RT Alignment" --- .github/workflows/ci.yaml | 2 +- .pre-commit-config.yaml | 2 +- docker/local_jupyter.sh | 2 +- docker/requirements.txt | 1 + docs/Targeted_Analysis.md | 40 +- docs/google_auth_copy_button.png | Bin 0 -> 77017 bytes example_config.yaml | 234 +++++ .../datastructures/analysis_identifiers.py | 172 ++-- metatlas/datastructures/id_types.py | 7 - metatlas/datastructures/metatlas_dataset.py | 144 +-- metatlas/datastructures/metatlas_objects.py | 20 +- metatlas/io/gdrive.py | 45 + metatlas/io/targeted_output.py | 217 +++- metatlas/plots/dill2plots.py | 8 +- metatlas/scripts/analysis_setup.sh | 39 - metatlas/targeted/__init__.py | 0 metatlas/targeted/process.py | 122 +++ metatlas/targeted/rt_alignment.py | 464 +++++++++ metatlas/tools/config.py | 198 ++++ metatlas/tools/fastanalysis.py | 1 + metatlas/tools/notebook.py | 10 + metatlas/tools/predict_rt.py | 957 ------------------ metatlas/tools/util.py | 12 +- ...RT_Prediction.ipynb => RT_Alignment.ipynb} | 147 +-- notebooks/reference/Targeted.ipynb | 131 ++- noxfile.py | 7 +- papermill/launch_rt_prediction.sh | 42 +- papermill/slurm_template.sh | 4 +- pyproject.toml | 2 + test_config.yaml | 91 ++ tests/system/test_add_msms_ref.py | 4 +- tests/system/test_c18.py | 16 +- ...est_rt_predict.py => test_rt_alignment.py} | 28 +- tests/system/test_targeted.py | 15 +- tests/system/utils.py | 6 +- tests/unit/conftest.py | 86 +- tests/unit/test_analysis_identifiers.py | 86 +- tests/unit/test_config.py | 14 + tests/unit/test_datastructure_utils.py | 2 +- tests/unit/test_dill2plot.py | 16 + tests/unit/test_metatlas_dataset.py | 71 +- .../unit/test_metatlas_get_data_helper_fun.py | 2 +- tests/unit/test_metatlas_objects.py | 14 + ...est_predict_rt.py => test_rt_alignment.py} | 24 +- tests/unit/test_targeted_output.py | 16 +- tests/unit/test_targeted_process.py | 30 + utils/rclone_auth.sh | 34 +- 47 files changed, 1935 insertions(+), 1650 deletions(-) create mode 100644 docs/google_auth_copy_button.png create mode 100644 example_config.yaml create mode 100644 metatlas/io/gdrive.py delete mode 100755 metatlas/scripts/analysis_setup.sh create mode 100644 metatlas/targeted/__init__.py create mode 100644 metatlas/targeted/process.py create mode 100644 metatlas/targeted/rt_alignment.py create mode 100644 metatlas/tools/config.py delete mode 100644 metatlas/tools/predict_rt.py rename notebooks/reference/{RT_Prediction.ipynb => RT_Alignment.ipynb} (62%) create mode 100644 test_config.yaml rename tests/system/{test_rt_predict.py => test_rt_alignment.py} (78%) create mode 100644 tests/unit/test_config.py rename tests/unit/{test_predict_rt.py => test_rt_alignment.py} (54%) create mode 100644 tests/unit/test_targeted_process.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2a64978d..19007914 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,7 +38,7 @@ jobs: - name: Setup nox uses: excitedleigh/setup-nox@4c62aee44396909396d10137c747b2633deeee76 - name: Run system tests - run: nox -s system_tests-3.8 -- -k test_rt_predict + run: nox -s system_tests-3.8 -- -k test_rt_alignment system_test3: name: Run system test 3 - add MSMS references runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 693ba1a8..99ab2f4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: trailing-whitespace - id: mixed-line-ending - repo: https://github.com/zricethezav/gitleaks - rev: v8.8.7 + rev: v8.8.12 hooks: - id: gitleaks - repo: local diff --git a/docker/local_jupyter.sh b/docker/local_jupyter.sh index 19542dfd..74dbf0f6 100755 --- a/docker/local_jupyter.sh +++ b/docker/local_jupyter.sh @@ -5,7 +5,7 @@ set -euf -o pipefail SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" REPO_DIR=$(dirname "$SCRIPT_DIR") OUT_DIR="${SCRIPT_DIR}/out" -IMAGE='registry.spin.nersc.gov/metatlas_test/metatlas_ci01:v1.4.20' +IMAGE='registry.spin.nersc.gov/metatlas_test/metatlas_ci01:v1.4.22' PORT=8888 while [[ "$#" -gt 0 ]]; do diff --git a/docker/requirements.txt b/docker/requirements.txt index 6710afa1..e4d61e56 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -20,6 +20,7 @@ pandas==1.4.2 papermill==2.3.4 pip==22.1.2 pubchempy==1.0.4 +pydantic==1.9.1 pymysql==1.0.2 pymzml==2.5.1 pyyaml==6.0 diff --git a/docs/Targeted_Analysis.md b/docs/Targeted_Analysis.md index 28e84dcf..1c53fad8 100644 --- a/docs/Targeted_Analysis.md +++ b/docs/Targeted_Analysis.md @@ -16,14 +16,32 @@ Analysts need to be in the metatlas group at NERSC. You can check if you are in ``` /global/common/software/m2650/metatlas-repo/utils/rclone_auth.sh ``` -5. The output from step 4 will include a URL that you should copy into and open with a web browser that is logged into your LBL Google account. +5. The output from step 4 will include a URL that you should copy into and open with a web browser that is logged in to your LBL Google account. 6. You will be prompted to authorize RClone to have edit access to Google Drive. Select your lbl.gov Google Account and then click the 'Allow' button. 7. Click the clipboard icon to copy the authorization code. + +![clipboard icon screen shot](google_auth_copy_button.png) + 8. Go back to the JupyterLab page and paste the authorization code into the terminal and hit 'Enter'. 9. To verify you RClone configuration was successful, copy and paste the following command into the terminal: + + ``` + /global/cfs/cdirs/m342/USA/shared-envs/rclone/bin/rclone ls rclone_test:sub + ``` + + Which should yield: + ``` + 119 If_you_see_this_then_RClone_has_been_properly_configured.txt + ``` + +10. If you will be working on JGI data, then check that you have access to the + [JGI_Metabolomics_Projects Google Drive folder](https://drive.google.com/drive/folders/0B-ZDcHbPi-aqZzE5V3hOZFc0dms) + by copying and pasting the following command into the terminal: + ``` /global/cfs/cdirs/m342/USA/shared-envs/rclone/bin/rclone lsd metabolomics:Analysis_uploads ``` + Which should yield a listing of metabolomics experiment names similar to: ``` -1 2021-08-30 10:01:06 -1 20210323_JGI-AK_SS_504264_GEBA_Pantoea-final_QE-HF_HILICZ_USHXG01602 @@ -43,7 +61,7 @@ Analysts need to be in the metatlas group at NERSC. You can check if you are in then you need to request access to the [JGI_Metabolomics_Projects Google Drive folder](https://drive.google.com/drive/folders/0B-ZDcHbPi-aqZzE5V3hOZFc0dms). - Please repeat step 9 after you have been granted access. + Please repeat step 10 after you have been granted access. ### Make a directory to store work in progress @@ -58,36 +76,36 @@ mkdir -p ~/metabolomics_data ### Perform RT correction #### Set Parameters -The `experiment_name` parameter can retrieved from the [Sample Tracking and QC Checkpoints - Northen Lab](https://docs.google.com/spreadsheets/d/126t1OeXQnCCgP6e-6Pac_Ku_A1R7MQLm_tl_Dkqsv_w/edit#gid=1548851545) Google Sheet. The experiment names can be found on the 'New Extraction' sheet in either column 'N' or 'O' depending on the type of chromatography that was performed. This value will be something like `20210723_JGI-AK_DB-TM_506963_LemCreek_final_QE-HF_HILICZ_USHXG01494`. +The `workflow_name` parameter will be supplied by Katherine or Suzie. For JGI projects, it will likely be one of `JGI-HILIC` or `JGI-C18`. -The `rt_predict_number` parameter is an integer that you'll need to increment if you re-run the RT correction. It should be set to 0 initially. +The `experiment_name` parameter can retrieved from the [Sample Tracking and QC Checkpoints - Northen Lab](https://docs.google.com/spreadsheets/d/126t1OeXQnCCgP6e-6Pac_Ku_A1R7MQLm_tl_Dkqsv_w/edit#gid=1548851545) Google Sheet. The experiment names can be found on the 'New Extraction' sheet in either column 'N' or 'O' depending on the type of chromatography that was performed. This value will be something like `20210723_JGI-AK_DB-TM_506963_LemCreek_final_QE-HF_HILICZ_USHXG01494`. -The `project_directory` is where you want to store the analysis while working on it. You should use `~/metabolomics_data`. +The `rt_predict_number` parameter is an integer that you'll need to increment if you re-run the RT alignment step. It should be set to 0 initially. #### Run `launch_rt_prediction.sh` In your JupyterLab terminal, run the following command (where you substitute the 3 parameters described above): ``` -/global/common/software/m2650/metatlas-repo/papermill/launch_rt_prediction.sh experiment_name rt_predict_number project_directory +/global/common/software/m2650/metatlas-repo/papermill/launch_rt_prediction.sh workflow_name experiment_name rt_predict_number ``` For example, your command with the parameters substituted in will be something like: ``` -/global/common/software/m2650/metatlas-repo/papermill/launch_rt_prediction.sh 20210804_JGI-AK_PA-CT_507784_Frtlzr_Set1_QE-139_HILICZ_USHXG01490 0 ~/metabolomics_data +/global/common/software/m2650/metatlas-repo/papermill/launch_rt_prediction.sh JGI-HILIC 20210804_JGI-AK_PA-CT_507784_Frtlzr_Set1_QE-139_HILICZ_USHXG01490 0 ``` This will submit a slurm job. On Cori, you will receive an email when the job starts executing and when it has completed. On Perlmutter, the SLRUM job notifications emails are currently broken. Typical HILIC jobs take 2 to 5 hours to complete. #### Evaluate Outputs -Once the job has completed, you should check the files generated to make sure the RT correction models look acceptable. You can find the output PDF files at `~/metabolomics_data//__0/data_QC/`. One easy way to view these files is to open them from the [Jupyter](https://jupyter.nersc.gov/) file browser. In `Actual_vs_Predicted_RTs.pdf`, you want to check that the default model (median-based RT correction and polynomial model) gives a good fit. At the bottom of the `Actual_vs_Predicted_RTs.pdf`, you can find the 'FileIndex' number that corresponds to the 'median' correction. Once you have determined the 'FileIndex' for median, you want to find the plot that has 'File: \' above it. This is the plot showing the models for the median-based RT correction. On each plot, there should be a red line (linear model) and green line (polynomial model). In many cases the lines for these models will almost be right on top of each other and you might not be able to see both of the lines unless you zoom in near the line ends. +Once the job has completed, you should check the files generated to make sure the RT correction models look acceptable. You can find the output PDF files at `~/metabolomics_data//__0/Targeted//RT_Alignment/`. One easy way to view these files is to open them from the [Jupyter](https://jupyter.nersc.gov/) file browser. In `Actual_vs_Predicted_RTs.pdf`, you want to check that the default model (median-based RT correction and polynomial model) gives a good fit. At the bottom of the `Actual_vs_Predicted_RTs.pdf`, you can find the 'FileIndex' number that corresponds to the 'median' correction. Once you have determined the 'FileIndex' for median, you want to find the plot that has 'File: \' above it. This is the plot showing the models for the median-based RT correction. On each plot, there should be a red line (linear model) and green line (polynomial model). In many cases the lines for these models will almost be right on top of each other and you might not be able to see both of the lines unless you zoom in near the line ends. If the median-based polynomial model does not give a good fit, then you will want to re-run `launch_rt_predictions.sh` with additional parameters (and an incremented `rt_predict_number`). See [Passing Additional Notebook Parameters To launch_rt_predictions.sh](#passing-additional-notebook-parameters-to-launch_rt_predictionsh) to learn how to pass the parameters. The two most relevant parameters for choosing a different model are `use_poly_model` and `dependent_data_source`. Documentation of the parameters and their possible values can be found in the first code block of the [RT_prediction.ipynb](https://github.com/biorack/metatlas/blob/main/notebooks/reference/RT_Prediction.ipynb) notebook. ### Perform ISTDsEtc Analysis 1. Launch [jupyter.nersc.gov](https://jupyter.nersc.gov/) in your web browser and start a 'Shared CPU Node' on Cori or Perlmutter. -2. Open `~/metabolomics_data//__0/_ISTDsEtc_POS.ipynb` within JupyterLab (you no longer need to use the Classic Notebook interface). If you are prompted to select a kernel, select 'Metatlas Targeted'. +2. Open `~/metabolomics_data//__0/Targeted//__ISTDsEtc-POS.ipynb` within JupyterLab (you no longer need to use the Classic Notebook interface). If you are prompted to select a kernel, select 'Metatlas Targeted'. 3. The first code cell of the notebook contains descriptions of the parameters and their default values. The second code cell of the notebook contain parameter values that were auto-populated from the RT correction slurm job. These values in the second code block will override the default values from the first code block. The third code block validates your parameter values and also validates that your environment is correctly configured. Execute the first 3 code cells and see if there are any errors. If you get an error message (usually error messages will be in red), you will need to correct the issue so that the cell executes without giving an error before moving on. The error messages commonly see at this point in the workflow generally include some description of what action is needed to correct the problem. 4. Execute the code blocks 4 and 5 to read in data and bring up the Annotation GUI. 5. For each of the compound-adduct pairs in your atlas, set the RT min and RT max boundaries to just contain the EIC peak that corresponds to the compound you are currently evaluating. For each compound-adduct pair, you must either select one of the MSMS-quality descriptors (upper set of radio buttons) or use the bottom set of radio buttons to mark the compound-adduct pair for removal. Failure to set either MSMS-quality descriptors or the remove state for each compound-adduct pair will result in the subsequent step throwing an error. @@ -100,7 +118,7 @@ If the median-based polynomial model does not give a good fit, then you will wan ### Perform FinalEMA-HILIC Analysis -1. Follow the same steps as the ISTDsEtc analysis except use the notebook name `_FinalEMA-HILIC_POS.ipynb`. +1. Follow the same steps as the ISTDsEtc analysis except use the notebook name `_-_EMA-POS.ipynb`. 2. Open the `POS__Final_Identifications.xlsx` file in the output directory on Google Drive. 3. Make sure everything looks as expected in the spreadsheet. 4. If there are any compound-adduct pairs that need to be removed at this point (because they are duplicated or you can now determine a similar compound was a better match for a given peak), you can place 'REMOVE' in columns B, M, and N. In columns B and N you should also include some description such as 'REMOVE - duplicate' or 'REMOVE - other isomer preferred (tryptophan matches MSMS reference)' or 'REMOVE - other isomer preferred (tryptophan matches reference RT)'. @@ -144,7 +162,7 @@ The `-p` and `-y` options can be used at the same time. An example usage of `-p` and `-y`: ``` /global/common/software/m2650/metatlas-repo/papermill/launch_rt_prediction.sh \ - 20210804_JGI-AK_PA-CT_507784_Frtlzr_Set1_QE-139_HILICZ_USHXG01490 0 ~/metabolomics_data \ + JGI-HILIC 20210804_JGI-AK_PA-CT_507784_Frtlzr_Set1_QE-139_HILICZ_USHXG01490 0 \ -y “{'rt_min_delta': -1.5, 'rt_max_delta': 1.5, 'inchi_keys_not_in_model': [‘CZMRCDWAGMRECN-UGDNZRGBSA-N', 'ISAKRJDGNUQOIC-UHFFFAOYSA-N']}" \ -p stop_before=atlases ``` diff --git a/docs/google_auth_copy_button.png b/docs/google_auth_copy_button.png new file mode 100644 index 0000000000000000000000000000000000000000..3aa476eb18a4d430aedc7d76f07008882ecee367 GIT binary patch literal 77017 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 XjKP)K%rRupfRC}hg None: super().__init__() self.set_trait("project_directory", project_directory) self.set_trait("experiment", experiment) - self.set_trait("output_type", output_type) - self.set_trait("polarity", polarity) + self.set_trait("polarity", Polarity(or_default(polarity, "positive"))) self.set_trait("analysis_number", analysis_number) - self.set_trait("rt_predict_number", rt_predict_number) + self.set_trait("rt_alignment_number", rt_alignment_number) self.set_trait("google_folder", google_folder) - self.set_trait("source_atlas", source_atlas) + with self.hold_trait_notifications(): + self.set_trait("source_atlas", source_atlas) + self.set_trait("source_atlas_username", or_default(source_atlas_username, getpass.getuser())) self.set_trait("copy_atlas", copy_atlas) self.set_trait("username", or_default(username, getpass.getuser())) self.set_trait("exclude_files", or_default(exclude_files, [])) - self.set_trait("include_groups", or_default(include_groups, self._default_include_groups)) - self.set_trait("exclude_groups", or_default(exclude_groups, self._default_exclude_groups)) - self.set_trait( - "groups_controlled_vocab", or_default(groups_controlled_vocab, DEFAULT_GROUPS_CONTROLLED_VOCAB) - ) + self.set_trait("include_groups", include_groups) + self.set_trait("exclude_groups", or_default(exclude_groups, [])) + self.set_trait("groups_controlled_vocab", or_default(groups_controlled_vocab, [])) self.set_trait("_lcmsruns", lcmsruns) self.set_trait("_all_groups", all_groups) + self.set_trait("configuration", configuration) + self.set_trait("workflow", workflow) + self.set_trait("analysis", analysis) logger.info( - "IDs: source_atlas=%s, atlas=%s, short_experiment_analysis=%s, output_dir=%s", + "IDs: source_atlas=%s, atlas=%s, output_dir=%s", self.source_atlas, self.atlas, - self.short_experiment_analysis, self.output_dir, ) self.store_all_groups(exist_ok=True) - self.set_trait("exclude_groups", append_inverse(self.exclude_groups, self.polarity)) - - @property - def _default_include_groups(self) -> GroupMatchList: - if self.output_type == "data_QC": - return ["QC"] - return [] - - def _get_default_exclude_groups(self, polarity: Polarity) -> GroupMatchList: - out: GroupMatchList = ["InjBl", "InjBL"] - if self.output_type not in ["data_QC"]: - out.append("QC") - return append_inverse(out, polarity) - - @property - def _default_exclude_groups(self) -> GroupMatchList: - return self._get_default_exclude_groups(self.polarity) @validate("polarity") def _valid_polarity(self, proposal: Proposal) -> Polarity: @@ -140,18 +128,13 @@ def _valid_polarity(self, proposal: Proposal) -> Polarity: raise TraitError(f"Parameter polarity must be one of {', '.join(POLARITIES)}") return cast(Polarity, proposal["value"]) - @validate("output_type") - def _valid_output_type(self, proposal: Proposal) -> OutputType: - if proposal["value"] not in OUTPUT_TYPES: - raise TraitError(f"Parameter output_type must be one of {', '.join(OUTPUT_TYPES)}") - return cast(OutputType, proposal["value"]) - @validate("source_atlas") def _valid_source_atlas(self, proposal: Proposal) -> Optional[AtlasName]: if proposal["value"] is not None: proposed_name = cast(AtlasName, proposal["value"]) try: - get_atlas(proposed_name, cast(Username, "*")) # raises error if not found or matches multiple + # raises error if not found or matches multiple + get_atlas(proposed_name, self.source_atlas_username) except ValueError as err: raise TraitError(str(err)) from err return proposed_name @@ -164,11 +147,11 @@ def _valid_analysis_number(self, proposal: Proposal) -> IterationNumber: raise TraitError("Parameter analysis_number cannot be negative.") return value - @validate("rt_predict_number") - def _valid_rt_predict_number(self, proposal: Proposal) -> IterationNumber: + @validate("rt_alignment_number") + def _valid_rt_alignment_number(self, proposal: Proposal) -> IterationNumber: value = cast(IterationNumber, proposal["value"]) if value < 0: - raise TraitError("Parameter rt_predict_number cannot be negative.") + raise TraitError("Parameter rt_alignment_number cannot be negative.") return value @validate("experiment") @@ -198,21 +181,14 @@ def project(self) -> str: @property def atlas(self) -> AtlasName: """Atlas identifier (name)""" - if self.source_atlas is None or (self.copy_atlas and self.source_atlas is not None): - return AtlasName( - f"{'_'.join(self._exp_tokens[3:6])}_{self.output_type}_{self.short_polarity}_{self.analysis}" - ) + if self.copy_atlas: + return AtlasName(f"{'_'.join(self._exp_tokens[3:6])}_{self.source_atlas}_{self.execution}") return self.source_atlas @property - def analysis(self) -> str: - """Analysis identifier""" - return f"{self.username}_{self.rt_predict_number}_{self.analysis_number}" - - @property - def short_experiment_analysis(self) -> str: - """Short experiment analysis identifier""" - return f"{self._exp_tokens[0]}_{self._exp_tokens[3]}_{self.output_type}_{self.analysis}" + def execution(self) -> str: + """execution identifier""" + return f"{self.username}_{self.rt_alignment_number}_{self.analysis_number}" @property def short_polarity(self) -> ShortPolarity: @@ -227,7 +203,7 @@ def short_polarity_inverse(self) -> List[ShortPolarity]: @property def output_dir(self) -> PathString: """Creates the output directory and returns the path as a string""" - sub_dirs = [self.experiment, self.analysis, self.output_type, self.short_polarity] + sub_dirs = [self.experiment, self.execution, "Targeted", self.workflow, self.analysis] out = os.path.join(self.project_directory, *sub_dirs) os.makedirs(out, exist_ok=True) return PathString(out) @@ -344,13 +320,7 @@ def groups(self) -> List[metob.Group]: if self.exclude_groups is not None and len(self.exclude_groups) > 0: out = dp.remove_metatlas_objects_by_list(out, "name", self.exclude_groups) self.set_trait("_groups", dp.filter_empty_metatlas_objects(out, "items")) - return self._groups or [] - - @observe("polarity") - def _observe_polarity(self, signal: ObserveHandler) -> None: - if signal.type == "change": - self.set_trait("exclude_groups", self._get_default_exclude_groups(signal.new)) - logger.debug("Change to polarity invalidates exclude_groups") + return sorted(self._groups, key=lambda x: x.name) @observe("_all_groups") def _observe_all_groups(self, signal: ObserveHandler) -> None: @@ -391,7 +361,7 @@ def _observe_lcmsruns(self, signal: ObserveHandler) -> None: @property def existing_groups(self) -> List[metob.Group]: """Get your own groups that are prefixed by self.experiment""" - return metob.retrieve("Groups", name=f"{self.experiment}%{self.analysis}_%", username=self.username) + return metob.retrieve("Groups", name=f"{self.experiment}%{self.execution}_%", username=self.username) def group_name(self, base_filename: str) -> Dict[str, str]: """Returns dict with keys group and short_name corresponding to base_filename""" @@ -403,7 +373,7 @@ def group_name(self, base_filename: str) -> Dict[str, str]: if s.lower() in base_filename.lower() ] suffix = self.groups_controlled_vocab[indices[0]].lstrip("_") if indices else tokens[12] - group_name = f"{prefix}_{self.analysis}_{suffix}" + group_name = f"{prefix}_{self.execution}_{suffix}" short_name = f"{tokens[9]}_{suffix}" # Prepending POL to short_name return {"group": group_name, "short_name": short_name} @@ -424,31 +394,29 @@ def all_groups(self) -> List[metob.Group]: return self._all_groups unique_groups = self.all_groups_dataframe[["group", "short_name"]].drop_duplicates() self.set_trait("_all_groups", []) + assert self._all_groups is not None # needed for mypy for values in unique_groups.to_dict("index").values(): - if self._all_groups is not None: # needed for mypy - self._all_groups.append( - metob.Group( - name=values["group"], - short_name=values["short_name"], - items=[ - file_value["object"] - for file_value in self._files_dict.values() - if file_value["group"] == values["group"] - ], - ) + self._all_groups.append( + metob.Group( + name=values["group"], + short_name=values["short_name"], + items=[ + file_value["object"] + for file_value in self._files_dict.values() + if file_value["group"] == values["group"] + ], ) - return self._all_groups or [] + ) + return sorted(self._all_groups, key=lambda x: x.name) @property def chromatography(self) -> str: """returns the type of chromatography used""" - alternatives: Dict[str, List[str]] = {"HILIC": ["HILICZ", "Ag683775"], "C18": []} + alternatives = {t.name: t.aliases for t in self.configuration.chromatography_types} chrom_field = self.lcmsruns[0].name.split("_")[7] chrom_type = chrom_field.split("-")[0] - if chrom_type in alternatives: - return chrom_type for name, alt_list in alternatives.items(): - if chrom_type in alt_list: + if chrom_type == name or chrom_type in alt_list: return name logger.warning("Unknown chromatography field '%s'.", chrom_type) return chrom_type @@ -475,21 +443,3 @@ def store_all_groups(self, exist_ok: bool = False) -> None: raise err logger.debug("Storing %d groups in the database", len(self.all_groups)) metob.store(self.all_groups) - - def remove_from_exclude_groups(self, remove_groups: List[str]) -> None: - """Remove items in remove_groups from exclude_groups""" - self.set_trait("exclude_groups", remove_items(self.exclude_groups, remove_groups)) - - -def append_inverse(in_list: List[str], polarity: Polarity) -> List[str]: - """appends short version of inverse of polarity to and retuns the list""" - inverse = {"positive": "NEG", "negative": "POS"} - return in_list + [inverse[polarity]] if polarity in inverse else in_list - - -def remove_items(edit_list: List[str], remove_list: List[str], ignore_case: bool = True) -> List[str]: - """Returns list of items in edit_list but not in remove_list""" - if ignore_case: - lower_remove_list = [x.lower() for x in remove_list] - return [x for x in edit_list if x.lower() not in lower_remove_list] - return [x for x in edit_list if x not in remove_list] diff --git a/metatlas/datastructures/id_types.py b/metatlas/datastructures/id_types.py index 3b5991e7..dfe7e9f3 100644 --- a/metatlas/datastructures/id_types.py +++ b/metatlas/datastructures/id_types.py @@ -18,13 +18,6 @@ IterationNumber = NewType("IterationNumber", int) PathString = NewType("PathString", str) -OUTPUT_TYPES = [ - OutputType("ISTDsEtc"), - OutputType("FinalEMA-HILIC"), - OutputType("FinalEMA-C18"), - OutputType("data_QC"), - OutputType("other"), -] POLARITIES = [Polarity("positive"), Polarity("negative"), Polarity("fast-polarity-switching")] SHORT_POLARITIES = { Polarity("positive"): ShortPolarity("POS"), diff --git a/metatlas/datastructures/metatlas_dataset.py b/metatlas/datastructures/metatlas_dataset.py index be698a82..ce29ad80 100644 --- a/metatlas/datastructures/metatlas_dataset.py +++ b/metatlas/datastructures/metatlas_dataset.py @@ -1,7 +1,6 @@ """ object oriented interface to metatlas_dataset """ import datetime -import getpass import glob import logging import os @@ -16,28 +15,18 @@ import pandas as pd import traitlets - -from IPython.display import display from traitlets import default, observe from traitlets import Bool, Float, HasTraits, Instance, Int, Unicode from traitlets.traitlets import ObserveHandler import metatlas.plots.dill2plots as dp import metatlas.datastructures.analysis_identifiers as analysis_ids -from metatlas.datastructures.id_types import ( - IterationNumber, - Experiment, - FileMatchList, - GroupMatchList, - PathString, - Polarity, - OutputType, -) + +from metatlas.datastructures.id_types import PathString, Polarity from metatlas.datastructures import metatlas_objects as metob from metatlas.datastructures import object_helpers as metoh from metatlas.datastructures.utils import AtlasName, get_atlas, Username from metatlas.io import metatlas_get_data_helper_fun as ma_data -from metatlas.io import targeted_output from metatlas.tools import parallel logger = logging.getLogger(__name__) @@ -151,6 +140,8 @@ class MetatlasDataset(HasTraits): msms_refs_loc: PathString = Unicode(default_value=analysis_ids.MSMS_REFS_PATH) ids: analysis_ids.AnalysisIdentifiers = Instance(klass=analysis_ids.AnalysisIdentifiers) atlas: metob.Atlas = Instance(klass=metob.Atlas) + rt_min_delta = Int(allow_none=True, default_value=None) + rt_max_delta = Int(allow_none=True, default_value=None) _atlas_df: Optional[pd.DataFrame] = Instance(klass=pd.DataFrame, allow_none=True, default_value=None) _data: Optional[Tuple[MetatlasSample, ...]] = traitlets.Tuple(allow_none=True, default_value=None) _hits: Optional[pd.DataFrame] = Instance(klass=pd.DataFrame, allow_none=True, default_value=None) @@ -162,8 +153,7 @@ def __init__(self, **kwargs) -> None: logger.debug("Creating new MetatlasDataset instance...") self._hits_valid_for_rt_bounds = False # based only on RT min/max changes self._data_valid_for_rt_bounds = False # based only on RT min/max changes - if self.ids.source_atlas is not None: - self._get_atlas() + self._get_atlas() if self.save_metadata: logger.debug("Writing MetatlasDataset metadata files") self.write_data_source_files() @@ -225,6 +215,7 @@ def _get_atlas(self) -> None: If the atlas does not yet exist, it will be copied from source_atlas and there will be an an additional side effect that all mz_tolerances in the resulting atlas get their value from source_atlas' atlas.compound_identifications[0].mz_references[0].mz_tolerance + Adjusts rt_min and rt_max if rt_min_delta or rt_max_delta are not None """ atlases = metob.retrieve("Atlas", name=self.ids.atlas, username=self.ids.username) if len(atlases) == 1: @@ -236,7 +227,7 @@ def _get_atlas(self) -> None: self.ids.atlas, self.ids.source_atlas, ) - self.atlas = atlases[0] + temp_atlas = atlases[0] elif len(atlases) > 1: try: raise ValueError( @@ -248,14 +239,9 @@ def _get_atlas(self) -> None: except ValueError as err: logger.exception(err) raise err - elif self.ids.source_atlas is not None: - self.atlas = self._clone_source_atlas() else: - try: - raise ValueError("Could not load atlas as source_atlas is None.") - except ValueError as err: - logger.exception(err) - raise err + temp_atlas = self._clone_source_atlas() + self.atlas = metob.adjust_atlas_rt_range(temp_atlas, self.rt_min_delta, self.rt_max_delta) def _clone_source_atlas(self) -> metob.Atlas: logger.info("Retriving source atlas: %s", self.ids.source_atlas) @@ -580,13 +566,15 @@ def hits(self) -> pd.DataFrame: def _get_hits_metadata(self) -> Dict[str, Any]: return { "_variable_name": "hits", - "polarity": self.ids.polarity, "extra_time": self.extra_time, "keep_nonmatches": self.keep_nonmatches, "frag_mz_tolerance": self.frag_mz_tolerance, "ref_loc": self.msms_refs_loc, "extra_mz": self.extra_mz, - "output_type": self.ids.output_type, + "source_atlas": self.ids.source_atlas, + "exclude_files": self.ids.exclude_files, + "exclude_groups": self.ids.exclude_groups, + "include_groups": self.ids.include_groups, } def __len__(self) -> int: @@ -688,63 +676,12 @@ def error_if_not_all_evaluated(self) -> None: logger.exception(err) raise err - def annotation_gui( - self, - compound_idx: int = 0, - width: float = 15, - height: float = 3, - alpha: float = 0.5, - colors="", - adjustable_rt_peak=False, - ) -> dp.adjust_rt_for_selected_compound: - """ - Opens the interactive GUI for setting RT bounds and annotating peaks - inputs: - compound_idx: number of compound-adduct pair to start at - width: width of interface in inches - height: height of each plot in inches - alpha: (0-1] controls transparency of lines on EIC plot - colors: list (color_id, search_string) for coloring lines on EIC plot - based on search_string occuring in LCMS run filename - """ - display(dp.LOGGING_WIDGET) # surface event handler error messages in UI - return dp.adjust_rt_for_selected_compound( - self, - msms_hits=self.hits, - color_me=colors, - compound_idx=compound_idx, - alpha=alpha, - width=width, - height=height, - adjustable_rt_peak=adjustable_rt_peak, - ) - - def generate_all_outputs(self, msms_fragment_ions: bool = False, overwrite: bool = False) -> None: - """ - Generates the default set of outputs for a targeted experiment - inputs: - msms_fragment_ions: if True, generate msms fragment ions report - overwrite: if False, throw error if any output files already exist - """ + def update(self) -> None: + """update hits and data if they no longer are based on current rt bounds""" if not self._hits_valid_for_rt_bounds: self._hits = None # force hits to be regenerated if not self._data_valid_for_rt_bounds: self._data = None # force data to be regenerated - self.extra_time = 0.5 - logger.info("extra_time set to 0.5 minutes for output generation.") - logger.info("Removing InjBl from exclude_groups.") - self.ids.remove_from_exclude_groups(["InjBl"]) - targeted_output.write_atlas_to_spreadsheet(self, overwrite=overwrite) - targeted_output.write_stats_table(self, overwrite=overwrite) - targeted_output.write_chromatograms(self, overwrite=overwrite, max_cpus=self.max_cpus) - targeted_output.write_identification_figure(self, overwrite=overwrite) - targeted_output.write_metrics_and_boxplots(self, overwrite=overwrite, max_cpus=self.max_cpus) - targeted_output.write_tics(self, overwrite=overwrite, x_min=1.5) - if msms_fragment_ions: - targeted_output.write_msms_fragment_ions(self, overwrite=overwrite) - logger.info("Generation of output files completed sucessfully.") - targeted_output.archive_outputs(self.ids) - targeted_output.copy_outputs_to_google_drive(self.ids) def _duration_since(start: datetime.datetime) -> str: @@ -820,54 +757,3 @@ def _error_if_bad_idxs(dataframe: pd.DataFrame, test_idx_list: List[int]) -> Non def quoted_string_list(strings: List[str]) -> str: """Adds double quotes around each string and seperates with ', '.""" return ", ".join([f'"{x}"' for x in strings]) - - -# pylint: disable=too-many-arguments,too-many-locals -def pre_annotation( - source_atlas: AtlasName, - experiment: Experiment, - output_type: OutputType, - polarity: Polarity, - analysis_number: IterationNumber, - project_directory: PathString, - google_folder: str, - groups_controlled_vocab: GroupMatchList, - exclude_files: FileMatchList, - num_points: int, - peak_height: float, - max_cpus: int, - msms_score: float = None, - username: Username = None, - clear_cache: bool = False, - rt_predict_number: int = 0, -) -> MetatlasDataset: - """All data processing that needs to occur before the annotation GUI in Targeted notebook""" - ids = analysis_ids.AnalysisIdentifiers( - source_atlas=source_atlas, - experiment=experiment, - output_type=output_type, - polarity=polarity, - analysis_number=analysis_number, - project_directory=project_directory, - google_folder=google_folder, - groups_controlled_vocab=groups_controlled_vocab, - exclude_files=exclude_files, - username=getpass.getuser() if username is None else username, - rt_predict_number=rt_predict_number, - ) - if clear_cache: - shutil.rmtree(ids.cache_dir) - metatlas_dataset = MetatlasDataset(ids=ids, max_cpus=max_cpus) - if "FinalEMA" in metatlas_dataset.ids.output_type: - metatlas_dataset.filter_compounds_by_signal(num_points, peak_height, msms_score) - return metatlas_dataset - - -def post_annotation(metatlas_dataset: MetatlasDataset, require_all_evaluated=True) -> None: - """All data processing that needs to occur after the annotation GUI in Targeted notebook""" - if "FinalEMA" in metatlas_dataset.ids.output_type: - if require_all_evaluated: - metatlas_dataset.error_if_not_all_evaluated() - metatlas_dataset.filter_compounds_ms1_notes_remove() - metatlas_dataset.generate_all_outputs() - logger.info("DONE - execution of notebook is complete.") diff --git a/metatlas/datastructures/metatlas_objects.py b/metatlas/datastructures/metatlas_objects.py index 4d920ca3..1f6fa34b 100644 --- a/metatlas/datastructures/metatlas_objects.py +++ b/metatlas/datastructures/metatlas_objects.py @@ -7,11 +7,11 @@ import time import uuid -from typing import Dict +from copy import deepcopy +from typing import Dict, Optional -from pwd import getpwuid -from tabulate import tabulate import pandas as pd +from tabulate import tabulate from .object_helpers import ( set_docstring, Workspace, format_timestamp, MetList, @@ -710,3 +710,17 @@ def to_dataframe(objects): for col in ['last_modified', 'creation_time']: dataframe[col] = pd.to_datetime(dataframe[col], unit='s') return dataframe + + +def adjust_atlas_rt_range( + in_atlas: Atlas, rt_min_delta: Optional[float], rt_max_delta: Optional[float] +) -> Atlas: + """Reset the rt_min and rt_max values by adding rt_min_delta or rt_max_delta to rt_peak""" + if rt_min_delta is None and rt_max_delta is None: + return in_atlas + out_atlas = deepcopy(in_atlas) + for cid in out_atlas.compound_identifications: + rts = cid.rt_references[0] + rts.rt_min = rts.rt_min if rt_min_delta is None else rts.rt_peak + rt_min_delta + rts.rt_max = rts.rt_max if rt_max_delta is None else rts.rt_peak + rt_max_delta + return out_atlas diff --git a/metatlas/io/gdrive.py b/metatlas/io/gdrive.py new file mode 100644 index 00000000..88ae0a8a --- /dev/null +++ b/metatlas/io/gdrive.py @@ -0,0 +1,45 @@ +"""Transfer files to Google Drive""" + +import logging +import os + +from pathlib import Path + +from IPython.core.display import display, HTML + +from metatlas.datastructures.analysis_identifiers import AnalysisIdentifiers +from metatlas.io import rclone + +logger = logging.getLogger(__name__) + +RCLONE_PATH = "/global/cfs/cdirs/m342/USA/shared-envs/rclone/bin/rclone" + + +def copy_outputs_to_google_drive(ids: AnalysisIdentifiers) -> None: + """ + Recursively copy the output files to Google Drive using rclone + Inputs: + ids: an AnalysisIds object + """ + logger.info("Copying output files to Google Drive") + rci = rclone.RClone(RCLONE_PATH) + fail_suffix = "not copying files to Google Drive" + if rci.config_file() is None: + logger.warning("RClone config file not found -- %s.", fail_suffix) + return + drive = rci.get_name_for_id(ids.google_folder) + if drive is None: + logger.warning( + "RClone config file does not contain Google Drive folder ID '%s' -- %s.", + ids.google_folder, + fail_suffix, + ) + return + folders = Path(ids.output_dir).parts[-5:] + sub_folders_string = os.path.join("Analysis_uploads", *folders) + rci.copy_to_drive(ids.output_dir, drive, sub_folders_string, progress=True) + logger.info("Done copying output files to Google Drive") + path_string = f"{drive}:{sub_folders_string}" + display( + HTML(f'Data is now on Google Drive at {path_string}') + ) diff --git a/metatlas/io/targeted_output.py b/metatlas/io/targeted_output.py index 23cd78fb..eab56fc2 100644 --- a/metatlas/io/targeted_output.py +++ b/metatlas/io/targeted_output.py @@ -2,35 +2,40 @@ # pylint: disable=too-many-arguments import logging +import math import os import tarfile from collections import namedtuple +from typing import List +import matplotlib.pyplot as plt +import matplotlib.ticker as mticker import numpy as np import pandas as pd -from IPython.core.display import display, HTML +from matplotlib import gridspec +from matplotlib.axis import Axis +from tqdm.notebook import tqdm -from metatlas.io import rclone +from metatlas.datastructures.metatlas_dataset import MetatlasDataset from metatlas.io.write_utils import export_dataframe_die_on_diff from metatlas.plots import dill2plots as dp from metatlas.tools import fastanalysis as fa +from metatlas.tools.config import Analysis from metatlas.plots.tic import save_sample_tic_pdf logger = logging.getLogger(__name__) -RCLONE_PATH = "/global/cfs/cdirs/m342/USA/shared-envs/rclone/bin/rclone" - -def write_atlas_to_spreadsheet(metatlas_dataset, overwrite=False): +def write_atlas_to_csv(metatlas_dataset, overwrite=False): """Save atlas as csv file. Will not overwrite existing file unless overwrite is True""" out_file_name = os.path.join(metatlas_dataset.ids.output_dir, f"{metatlas_dataset.atlas.name}_export.csv") out_df = dp.export_atlas_to_spreadsheet(metatlas_dataset.atlas) export_dataframe_die_on_diff(out_df, out_file_name, "atlas", overwrite=overwrite, float_format="%.6e") -def write_stats_table( +def write_identifications_spreadsheet( metatlas_dataset, min_intensity=1e4, rt_tolerance=0.5, @@ -81,7 +86,7 @@ def write_stats_table( input_dataset=metatlas_dataset, msms_hits=metatlas_dataset.hits, output_loc=ids.output_dir, - output_sheetname=f"{ids.project}_{ids.output_type}_Identifications.xlsx", + output_sheetname=f"{ids.project}_{ids.workflow}_{ids.analysis}_Identifications.xlsx", min_peak_height=1e5, use_labels=True, min_msms_score=0.01, @@ -320,37 +325,185 @@ def archive_outputs(ids): ids: an AnalysisIds object """ logger.info("Generating archive of output files.") - suffix = "" if ids.output_type == "data_QC" else f"-{ids.short_polarity}" - output_file = f"{ids.short_experiment_analysis}{suffix}.tar.gz" + output_file = f"{ids.atlas}.tar.gz" output_path = os.path.join(ids.project_directory, ids.experiment, output_file) with tarfile.open(output_path, "w:gz") as tar: tar.add(ids.output_dir, arcname=os.path.basename(ids.output_dir)) logger.info("Generation of archive completed succesfully: %s", output_path) -def copy_outputs_to_google_drive(ids): +def generate_all_outputs( + data: MetatlasDataset, + analysis: Analysis, + overwrite: bool = False, +) -> None: + """Generates the default set of outputs for a targeted experiment""" + write_atlas_to_csv(data, overwrite=overwrite) + write_identifications_spreadsheet(data, overwrite=overwrite) + write_chromatograms(data, overwrite=overwrite, max_cpus=data.max_cpus) + write_identification_figure(data, overwrite=overwrite) + write_metrics_and_boxplots(data, overwrite=overwrite, max_cpus=data.max_cpus) + write_tics(data, overwrite=overwrite, x_min=1.5) + if analysis.parameters.export_msms_fragment_ions: + write_msms_fragment_ions(data, overwrite=overwrite) + archive_outputs(data.ids) + logger.info("Generation of output files completed sucessfully.") + + +def generate_qc_plots(data: MetatlasDataset) -> None: + """Write plots that can be used to QC the experiment""" + rts_df = get_rts(data) + compound_atlas_rts_file_name = os.path.join( + data.ids.output_dir, f"{data.ids.short_polarity}_Compound_Atlas_RTs.pdf" + ) + plot_compound_atlas_rts(len(data), rts_df, compound_atlas_rts_file_name) + peak_heights_df = get_peak_heights(data) + peak_heights_plot_file_name = os.path.join( + data.ids.output_dir, f"{data.ids.short_polarity}_Compound_Atlas_peak_heights.pdf" + ) + plot_compound_atlas_peak_heights(len(data), peak_heights_df, peak_heights_plot_file_name) + + +def generate_qc_outputs(data: MetatlasDataset) -> None: + """Write outputs that can be used to QC the experiment""" + ids = data.ids + save_rt_peak(data, os.path.join(ids.output_dir, f"{ids.short_polarity}_rt_peak.tab")) + save_measured_rts(data, os.path.join(ids.output_dir, f"{ids.short_polarity}_QC_Measured_RTs.csv")) + generate_qc_plots(data) + + +def save_measured_rts(data: MetatlasDataset, file_name: str) -> None: + """Save RT values in csv format file""" + rts_df = get_rts(data, include_atlas_rt_peak=False) + export_dataframe_die_on_diff(rts_df, file_name, "measured RT values", float_format="%.6e") + + +def save_rt_peak(data: MetatlasDataset, file_name: str) -> None: + """Save peak RT values in tsv format file""" + rts_df = dp.make_output_dataframe(input_dataset=data, fieldname="rt_peak", use_labels=True) + export_dataframe_die_on_diff(rts_df, file_name, "peak RT values", sep="\t", float_format="%.6e") + + +def get_rts(data: MetatlasDataset, include_atlas_rt_peak: bool = True) -> pd.DataFrame: + """Returns RT values in DataFrame format""" + rts_df = dp.make_output_dataframe( + input_dataset=data, + fieldname="rt_peak", + use_labels=True, + summarize=True, + ) + if include_atlas_rt_peak: + rts_df["atlas RT peak"] = [ + compound["identification"].rt_references[0].rt_peak for compound in data[0] + ] + return order_df_columns_by_run(rts_df) + + +def get_peak_heights(data: MetatlasDataset) -> pd.DataFrame: + """Returns peak heights in DataFrame format""" + peak_height_df = dp.make_output_dataframe( + input_dataset=data, + fieldname="peak_height", + use_labels=True, + summarize=True, + ) + return order_df_columns_by_run(peak_height_df) + + +def order_df_columns_by_run(dataframe: pd.DataFrame) -> pd.DataFrame: """ - Recursively copy the output files to Google Drive using rclone - Inputs: - ids: an AnalysisIds object + Returns a dataframe with re-ordered columns such that second column up to column 'mean' + are ordered by run number from low to high + """ + cols = dataframe.columns.tolist() + stats_start_idx = cols.index("mean") + to_sort = cols[:stats_start_idx] + no_sort = cols[stats_start_idx:] + to_sort.sort( + key=lambda x: int( + x.split(".")[0].split("_")[-1].lower().replace("run", "").replace("seq", "").replace("s", "") + ) + ) + new_cols = to_sort + no_sort + return dataframe[new_cols] + + +def plot_per_compound( + field_name: str, + num_files: int, + data: pd.DataFrame, + file_name: str, + fontsize: float = 2, + pad: float = 0.1, + cols: int = 8, +) -> None: + """ + Writes plot of RT peak for vs file for each compound + inputs: + field_name: one of rt_peak or peak_height + num_files: number of files in data set, ie len(data) + data: Dataframe with RTs values + file_name: where to save plot + fontsize: size of text + pad: padding size + cols: number of columns in plot grid """ - logger.info("Copying output files to Google Drive") - rci = rclone.RClone(RCLONE_PATH) - fail_suffix = "not copying files to Google Drive" - if rci.config_file() is None: - logger.warning("RClone config file not found -- %s.", fail_suffix) - return - drive = rci.get_name_for_id(ids.google_folder) - if drive is None: - logger.warning("RClone config file missing JGI_Metabolomics_Projects -- %s.", fail_suffix) - return - folders = [ids.experiment, ids.analysis, ids.output_type] - if ids.output_type != "data_QC": - folders.append(ids.short_polarity) - sub_folders_string = os.path.join("Analysis_uploads", *folders) - rci.copy_to_drive(ids.output_dir, drive, sub_folders_string, progress=True) - logger.info("Done copying output files to Google Drive") - path_string = f"{drive}:{sub_folders_string}" - display( - HTML(f'Data is now on Google Drive at {path_string}') + logger.info("Plotting %s vs file for each compound", field_name) + plot_df = ( + data.sort_values(by="standard deviation", ascending=False, na_position="last") + .drop(["#NaNs"], axis=1) + .dropna(axis=0, how="all", subset=data.columns[:num_files]) ) + rows = int(math.ceil((data.shape[0] + 1) / cols)) + fig = plt.figure() + grid = gridspec.GridSpec(rows, cols, figure=fig, wspace=0.2, hspace=0.4) + for i, (_, row) in tqdm(enumerate(plot_df.iterrows()), total=len(plot_df), unit="plot"): + a_x = fig.add_subplot(grid[i]) + range_columns = list(plot_df.columns[:num_files]) + file_vs_value_plot(a_x, field_name, row, range_columns, fontsize, pad) + plt.savefig(file_name, bbox_inches="tight") + plt.close() + + +def file_vs_value_plot( + a_x: Axis, field_name: str, row: pd.DataFrame, range_columns: List[str], fontsize: float, pad: float +) -> None: + """Create a dot plot with one point per file""" + assert field_name in ["rt_peak", "peak_height"] + a_x.tick_params(direction="in", length=1, pad=pad, width=0.1, labelsize=fontsize) + num_files = len(range_columns) + a_x.scatter(range(num_files), row[:num_files], s=0.2) + if field_name == "rt_peak": + a_x.axhline(y=row["atlas RT peak"], color="r", linestyle="-", linewidth=0.2) + range_columns += ["atlas RT peak"] + a_x.set_ylim(np.nanmin(row.loc[range_columns]) - 0.12, np.nanmax(row.loc[range_columns]) + 0.12) + else: + a_x.set_yscale("log") + a_x.set_ylim(bottom=1e4, top=1e10) + a_x.set_xlim(-0.5, num_files + 0.5) + a_x.xaxis.set_major_locator(mticker.FixedLocator(np.arange(0, num_files, 1.0))) + _ = [s.set_linewidth(0.1) for s in a_x.spines.values()] + # truncate name so it fits above a single subplot + a_x.set_title(row.name[:33], pad=pad, fontsize=fontsize) + a_x.set_xlabel("Files", labelpad=pad, fontsize=fontsize) + ylabel = "Actual RTs" if field_name == "rt_peak" else "Peak Height" + a_x.set_ylabel(ylabel, labelpad=pad, fontsize=fontsize) + + +def plot_compound_atlas_rts( + num_files: int, rts_df: pd.DataFrame, file_name: str, fontsize: float = 2, pad: float = 0.1, cols: int = 8 +) -> None: + """Plot filenames vs peak RT for each compound""" + plot_per_compound("rt_peak", num_files, rts_df, file_name, fontsize, pad, cols) + + +def plot_compound_atlas_peak_heights( + num_files: int, + peak_heights_df: pd.DataFrame, + file_name: str, + fontsize: float = 2, + pad: float = 0.1, + cols: int = 8, +) -> None: + """Plot filenames vs peak height for each compound""" + plot_per_compound("peak_height", num_files, peak_heights_df, file_name, fontsize, pad, cols) diff --git a/metatlas/plots/dill2plots.py b/metatlas/plots/dill2plots.py index e241fa72..da299f7b 100644 --- a/metatlas/plots/dill2plots.py +++ b/metatlas/plots/dill2plots.py @@ -169,7 +169,11 @@ class InstructionSet(object): def __init__(self, instructions_path): - self.data = pd.read_csv(instructions_path, dtype=str, na_values=[], keep_default_na=False) + try: + self.data = pd.read_csv(instructions_path, dtype=str, na_values=[], keep_default_na=False) + except FileNotFoundError: + logger.warning('Could not find instructions file %s.', instructions_path) + self.data = pd.DataFrame() def query(self, inchi_key, adduct, chromatography, polarity): inputs = {"inchi_key": inchi_key, "adduct": adduct, "chromatography": chromatography, "polarity": polarity} @@ -2845,7 +2849,7 @@ def get_metatlas_files(experiment: Union[str, Sequence[str]] = '%', name: str = )) if most_recent: files = filter_metatlas_objects_to_most_recent(files, 'mzml_file') - return files + return sorted(files, key=lambda x: x.name) def make_prefilled_fileinfo_sheet(groups, filename): diff --git a/metatlas/scripts/analysis_setup.sh b/metatlas/scripts/analysis_setup.sh deleted file mode 100755 index 3c472cf1..00000000 --- a/metatlas/scripts/analysis_setup.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -set -euf -o pipefail - -if [[ $# -ne 3 ]]; then - echo "Usage: $0 metatlas_repo_dir base_output_dir experiment_id" - exit 1 -fi - -REPO_DIR="$1" -OUT_DIR="$2" -EXP="$3" - -function install_kernel () { - local REPO_DIR="$1" - local SOURCE="${REPO_DIR}/notebooks/kernels/metatlas-targeted.kernel.json" - local DEST="${HOME}/.local/share/jupyter/kernels/metatlas-targeted/kernel.json" - if [[ ! -f "$DEST" ]]; then - mkdir -p $(dirname "$DEST") - cp "$SOURCE" "$DEST" - fi -} - -function validate_data_dir () { - local EXP="$1" -DATA_DIR="/project/projectdirs/metatlas/raw_data/akuftin/${EXP}" - -if [ ! -d "${DATA_DIR}" ]; then - echo "ERROR: could not find data directory ${DATA_DIR}." >&2 - exit 2 -fi - -function populate_experiment_dir () { -IFS='_' read -ra EXP_ARRAY <<< "$EXP" -NOTEBOOK_BASE="${EXP_ARRAY[3]}_${EXP_ARRAY[4]}" - -mkdir -p "${OUT_DIR}/${EXP}" -cp "${REPO_DIR}/notebooks/reference/Workflow_Notebook_VS_Auto_RT_Predict_V2.ipynb" "${OUT_DIR}/${EXP}/${NOTEBOOK_BASE}_RT_Predict.ipynb" -cp "${REPO_DIR}/notebooks/reference/Targeted.ipynb" "${OUT_DIR}/${EXP}/${NOTEBOOK_BASE}.ipynb" diff --git a/metatlas/targeted/__init__.py b/metatlas/targeted/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/metatlas/targeted/process.py b/metatlas/targeted/process.py new file mode 100644 index 00000000..099f96ec --- /dev/null +++ b/metatlas/targeted/process.py @@ -0,0 +1,122 @@ +""" process a targeted experiment""" + +import getpass +import logging +import shutil + +from typing import Optional + +from IPython.display import display + +import metatlas.datastructures.analysis_identifiers as analysis_ids +import metatlas.plots.dill2plots as dp + +from metatlas.datastructures.id_types import Experiment +from metatlas.datastructures.metatlas_dataset import MetatlasDataset +from metatlas.datastructures.utils import Username +from metatlas.io.gdrive import copy_outputs_to_google_drive +from metatlas.io.targeted_output import generate_all_outputs +from metatlas.tools.config import Config, Workflow, Analysis +from metatlas.tools.notebook import in_papermill +from metatlas.io.targeted_output import generate_qc_outputs + +logger = logging.getLogger(__name__) + + +# pylint: disable=too-many-arguments,too-many-locals +def pre_annotation( + experiment: Experiment, + rt_alignment_number: int, + analysis_number: int, + source_atlas: str, + configuration: Config, + workflow: Workflow, + analysis: Analysis, + clear_cache: bool = False, + username: Optional[Username] = None, +) -> MetatlasDataset: + """All data processing that needs to occur before the annotation GUI in Targeted notebook""" + params = analysis.parameters + ids = analysis_ids.AnalysisIdentifiers( + workflow=workflow.name, + analysis=analysis.name, + source_atlas=source_atlas, + copy_atlas=params.copy_atlas, + polarity=params.polarity, + analysis_number=analysis_number, + experiment=experiment, + include_groups=params.include_groups, + exclude_groups=params.exclude_groups, + groups_controlled_vocab=params.groups_controlled_vocab, + exclude_files=params.exclude_files, + rt_alignment_number=rt_alignment_number, + project_directory=params.project_directory, + google_folder=params.google_folder, + username=getpass.getuser() if username is None else username, + configuration=configuration, + ) + if clear_cache: + logger.info("Clearing cache.") + shutil.rmtree(ids.cache_dir) + metatlas_dataset = MetatlasDataset(ids=ids, max_cpus=params.max_cpus) + metatlas_dataset.filter_compounds_by_signal(params.num_points, params.peak_height, params.msms_score) + return metatlas_dataset + + +def annotation_gui( + data: MetatlasDataset, + compound_idx: int = 0, + width: float = 15, + height: float = 3, + alpha: float = 0.5, + colors="", + adjustable_rt_peak=False, +) -> Optional[dp.adjust_rt_for_selected_compound]: + """ + Opens the interactive GUI for setting RT bounds and annotating peaks + inputs: + compound_idx: number of compound-adduct pair to start at + width: width of interface in inches + height: height of each plot in inches + alpha: (0-1] controls transparency of lines on EIC plot + colors: list (color_id, search_string) for coloring lines on EIC plot + based on search_string occuring in LCMS run filename + """ + if in_papermill(): + logger.info("Non-interactive execution of notebook detected. Skipping annotation GUI.") + return None + display(dp.LOGGING_WIDGET) # surface event handler error messages in UI + return dp.adjust_rt_for_selected_compound( + data, + msms_hits=data.hits, + color_me=colors, + compound_idx=compound_idx, + alpha=alpha, + width=width, + height=height, + adjustable_rt_peak=adjustable_rt_peak, + ) + + +def post_annotation( + data: MetatlasDataset, configuration: Config, workflow: Workflow, analysis: Analysis +) -> None: + """All data processing that needs to occur after the annotation GUI in Targeted notebook""" + if analysis.parameters.require_all_evaluated and not in_papermill(): + data.error_if_not_all_evaluated() + if analysis.parameters.filter_removed: + data.filter_compounds_ms1_notes_remove() + data.extra_time = 0.5 + logger.info("extra_time set to 0.5 minutes for output generation.") + data.update() # update hits and data if they no longer are based on current rt bounds + if analysis.parameters.generate_qc_outputs: + generate_qc_outputs(data) + if analysis.parameters.generate_analysis_outputs: + logger.info( + "Setting exclude_groups to %s.", + str(analysis.parameters.exclude_groups_for_analysis_outputs), + ) + data.ids.set_trait("exclude_groups", analysis.parameters.exclude_groups_for_analysis_outputs) + generate_all_outputs(data, analysis) + copy_outputs_to_google_drive(data.ids) + logger.info("DONE - execution of notebook %s is complete.", "in draft mode" if in_papermill() else " ") diff --git a/metatlas/targeted/rt_alignment.py b/metatlas/targeted/rt_alignment.py new file mode 100644 index 00000000..69b662da --- /dev/null +++ b/metatlas/targeted/rt_alignment.py @@ -0,0 +1,464 @@ +"""Generate Model for Retention Time Alignment""" +# pylint: disable=too-many-arguments + +import logging +import math +import os + +from pathlib import Path +from typing import List, Optional, Tuple, Sequence + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import papermill + +from matplotlib import gridspec +from sklearn.base import BaseEstimator +from sklearn.linear_model import LinearRegression, RANSACRegressor +from sklearn.preprocessing import PolynomialFeatures +from tqdm.notebook import tqdm + +from metatlas.datastructures.analysis_identifiers import AnalysisIdentifiers +from metatlas.datastructures.metatlas_dataset import MetatlasDataset +from metatlas.datastructures import metatlas_objects as metob +from metatlas.datastructures.utils import get_atlas +from metatlas.io import metatlas_get_data_helper_fun as ma_data +from metatlas.io import targeted_output +from metatlas.io import write_utils +from metatlas.io.gdrive import copy_outputs_to_google_drive +from metatlas.plots import dill2plots as dp +from metatlas.tools import notebook +from metatlas.tools.config import Config, Workflow +from metatlas.tools.util import or_default, repo_path + +logger = logging.getLogger(__name__) + + +class Model: + """Encapsulate both linear and polynomial models in a consistent interface""" + + def __init__(self, sk_model: BaseEstimator, intercept: float, coefficents: np.ndarray): + """ + inputs: + sk_model: scikit-learn model object + intercept: y-intercept value + coefficents: a list of coefficents, with x^n coefficent at index n-1 + """ + self.sk_model = sk_model + self.intercept = intercept + if coefficents.shape == (1, 1): + self.coefficents = [intercept, coefficents[0][0]] + elif coefficents.shape == (1, 3): + self.coefficents = coefficents[0].tolist() + + def __repr__(self) -> str: + """Text description of the model function""" + if self.order == 1: + return f"Linear model with intercept={self.intercept:.3f} and slope={self.coefficents[1]:.5f}" + coef_str = ", ".join([f"{c:.5f}" for c in self.coefficents]) + return f"Polynomial model with intercept={self.intercept:.3f} and coefficents=[{coef_str}]" + + @property + def order(self) -> int: + """Polynomial order of the model""" + return len(self.coefficents) - 1 + + @property + def name(self) -> str: + """Type of model as string""" + return "linear" if self.order == 1 else "polynomial" + + def predict(self, x_values) -> List[float]: + """Returns y values for input x""" + x_transformed = x_values.reshape(-1, 1) + if self.order > 1: + poly_reg = PolynomialFeatures(degree=2) + x_transformed = poly_reg.fit_transform(x_transformed) + return self.sk_model.predict(x_transformed).flatten().tolist() + + +def generate_rt_alignment_models( + data: MetatlasDataset, + workflow: Workflow, +) -> Tuple[Model, Model]: + """ + Generate the RT correction models and model charaterization files + Returns a tuple with a linear and polynomial model + """ + params = workflow.rt_alignment.parameters + rts_df = get_rts(data) + actual, pred = subset_data_for_model_input( + params.dependent_data_source, rts_df, data.atlas_df, params.inchi_keys_not_in_model + ) + linear, poly = generate_models(actual, pred) + out_dir = Path(data.ids.output_dir) + actual_rts, aligned_rts = actual_and_aligned_rts(rts_df, data.atlas_df, params.inchi_keys_not_in_model) + actual_vs_pred_file_name = out_dir / "Actual_vs_Aligned_RTs.pdf" + plot_actual_vs_aligned_rts(aligned_rts, actual_rts, rts_df, str(actual_vs_pred_file_name), linear, poly) + rt_comparison_file_name = out_dir / "RT_Alignment_Model_Comparison.csv" + save_model_comparison( + params.dependent_data_source, data.atlas_df, rts_df, linear, poly, str(rt_comparison_file_name) + ) + models_file_name = out_dir / "rt_alignment_model.txt" + write_models(str(models_file_name), linear, poly, data.ids.groups, data.atlas) + return (linear, poly) + + +def generate_outputs(data: MetatlasDataset, workflow: Workflow) -> None: + """ + Generate the RT alignment models, associated atlases with relative RT values, follow up notebooks + """ + # pylint: disable=too-many-locals + params = workflow.rt_alignment.parameters + ids = data.ids + assert params.stop_before in ["atlases", "notebook_generation", "notebook_execution", None] + linear, poly = generate_rt_alignment_models(data, workflow) + if params.stop_before in ["notebook_generation", "notebook_execution", None]: + atlases = create_aligned_atlases(linear, poly, ids, workflow) + if params.stop_before in ["notebook_execution", None]: + notebook_file_names = write_notebooks(ids, atlases, workflow) + if params.stop_before is None: + for in_file_name in notebook_file_names: + out_file_name = in_file_name.with_name(in_file_name.stem + "_SLURM.ipynb") + papermill.execute_notebook(in_file_name, out_file_name, {}, kernel_name="papermill") + targeted_output.archive_outputs(ids) + copy_outputs_to_google_drive(ids) + logger.info("RT_Alignment notebook complete. Switch to an analysis notebook to continue.") + + +def get_rts(metatlas_dataset: MetatlasDataset, include_atlas_rt_peak: bool = True) -> pd.DataFrame: + """Returns RT values in DataFrame format""" + rts_df = dp.make_output_dataframe( + input_dataset=metatlas_dataset, + fieldname="rt_peak", + use_labels=True, + summarize=True, + ) + if include_atlas_rt_peak: + rts_df["atlas RT peak"] = [ + compound["identification"].rt_references[0].rt_peak for compound in metatlas_dataset[0] + ] + return targeted_output.order_df_columns_by_run(rts_df) + + +def generate_models(actual: List[float], pred: List[float]) -> Tuple[Model, Model]: + """ + inputs: + actual: experimental RTs + pred: predicted RTs + returns tuple containing two Model classes of order 1 and 2 + """ + transformed_actual = np.array(actual).reshape(-1, 1) + transformed_pred = np.array(pred).reshape(-1, 1) + + ransac = RANSACRegressor(random_state=42) + rt_model_linear = ransac.fit(transformed_pred, transformed_actual) + linear = Model( + rt_model_linear, rt_model_linear.estimator_.intercept_[0], rt_model_linear.estimator_.coef_ + ) + + poly_reg = PolynomialFeatures(degree=2) + x_poly = poly_reg.fit_transform(transformed_pred) + rt_model_poly = LinearRegression().fit(x_poly, transformed_actual) + poly = Model(rt_model_poly, rt_model_poly.intercept_[0], rt_model_poly.coef_) + return linear, poly + + +def subset_data_for_model_input( + selected_column: str, + rts_df: pd.DataFrame, + atlas_df: pd.DataFrame, + inchi_keys_not_in_model: Optional[List[str]] = None, +) -> Tuple[List[float], List[float]]: + """ + inputs: + selected_column: column number in rts_df to use for actual values + rts_df: dataframe of RT values + atlas_df: QC atlas in dataframe format + inchi_keys_not_in_model: InChi Keys that will be ignored for model creation + return a tuple of (actual, pred) + """ + keep_idxs = get_keep_idxs(selected_column, rts_df, atlas_df, inchi_keys_not_in_model) + actual = rts_df.iloc[keep_idxs][selected_column].tolist() + pred = atlas_df.iloc[keep_idxs]["rt_peak"].tolist() + return actual, pred + + +def get_keep_idxs( + selected_column: str, + rts_df: pd.DataFrame, + atlas_df: pd.DataFrame, + inchi_keys_not_in_model: Optional[List[str]] = None, +) -> List[int]: + """Indices in rts_df that should be used within the model""" + keep_idxs = set(np.flatnonzero(~np.isnan(rts_df.loc[:, selected_column]))) + if inchi_keys_not_in_model: + keep_idxs = keep_idxs.intersection( + set(np.flatnonzero(~atlas_df["inchi_key"].isin(inchi_keys_not_in_model))) + ) + return list(keep_idxs) + + +def actual_and_aligned_rts( + rts_df: pd.DataFrame, atlas_df: pd.DataFrame, inchi_keys_not_in_model: Optional[List[str]] = None +) -> Tuple[List[List[float]], List[List[float]]]: + """ + inputs: + rts_df: dataframe of RT values + atlas_df: QC atlas in dataframe format + inchi_keys_not_in_model: InChi Keys that will be ignored for model creation + return a tuple of lists of lists: (actual_rts, aligned_rts) + """ + actual_rts = [] + aligned_rts = [] + for i in range(rts_df.shape[1] - 5): + keep_idxs = get_keep_idxs(rts_df.columns[i], rts_df, atlas_df, inchi_keys_not_in_model) + current_actual_df = rts_df.loc[:, rts_df.columns[i]] + current_actual_df = current_actual_df.iloc[keep_idxs] + current_pred_df = atlas_df.iloc[keep_idxs][["rt_peak"]] + actual_rts.append(current_actual_df.values.tolist()) + aligned_rts.append(current_pred_df.values.tolist()) + return actual_rts, aligned_rts + + +def plot_actual_vs_aligned_rts( + aligned_rts: Sequence[Sequence[float]], + actual_rts: Sequence[Sequence[float]], + rts_df: pd.DataFrame, + file_name: str, + linear: Model, + poly: Model, +) -> None: + """Write scatter plot showing linear vs polynomial fit""" + # pylint: disable=too-many-locals + rows = int(math.ceil((rts_df.shape[1] + 1) / 5)) + cols = 5 + fig = plt.figure(constrained_layout=False) + grid = gridspec.GridSpec(rows, cols, figure=fig) + plt.rc("font", size=6) + plt.rc("axes", labelsize=6) + plt.rc("xtick", labelsize=3) + plt.rc("ytick", labelsize=3) + for i in range(rts_df.shape[1] - 5): + x_values = aligned_rts[i] + y_values = actual_rts[i] + if len(x_values) == 0 or len(y_values) == 0: + continue + sub = fig.add_subplot(grid[i]) + sub.scatter(x_values, y_values, s=2) + spaced_x = np.linspace(0, max(x_values), 100) + sub.plot(spaced_x, linear.predict(spaced_x), linewidth=0.5, color="red") + sub.plot(spaced_x, poly.predict(spaced_x), linewidth=0.5, color="green") + sub.set_title("File: " + str(i)) + sub.set_xlabel("relative RTs") + sub.set_ylabel("actual RTs") + fig_legend = [ + ( + "Red line: linear model; Green curve: polynomial model. " + "Default model is a polynomial model using the median data." + ), + "", + "file_index data_source", + ] + [f"{i:2d} {rts_df.columns[i]}" for i in range(rts_df.shape[1] - 5)] + fig.tight_layout(pad=0.5) + line_height = 0.03 + legend_offset = line_height * len(fig_legend) + plt.text(0, -1 * legend_offset, "\n".join(fig_legend), transform=plt.gcf().transFigure) + plt.savefig(file_name, bbox_inches="tight") + + +def save_model_comparison( + selected_column: str, + qc_atlas_df: pd.DataFrame, + rts_df: pd.DataFrame, + linear: Model, + poly: Model, + file_name: str, +) -> None: + """ + Save csv format file with per-compound comparision of linear vs polynomial models + inputs: + selected_column: column number in rts_df to use for actual values + qc_atlas_df: QC atlas in dataframe format + rts_df: dataframe with RT values + linear: instance of class Model with first order model + poly: instance of class Model with second order model + file_name: where to save the plot + """ + qc_df = rts_df[[selected_column]].copy() + qc_df.columns = ["RT Measured"] + qc_df.loc[:, "RT Reference"] = qc_atlas_df["rt_peak"].to_numpy() + qc_df.loc[:, "Relative RT Linear"] = pd.Series( + linear.predict(qc_df["RT Reference"].to_numpy()), index=qc_df.index + ) + qc_df.loc[:, "Relative RT Polynomial"] = pd.Series( + poly.predict(qc_df["RT Reference"].to_numpy()), index=qc_df.index + ) + qc_df["RT Diff Linear"] = qc_df["RT Measured"] - qc_df["Relative RT Linear"] + qc_df["RT Diff Polynomial"] = qc_df["RT Measured"] - qc_df["Relative RT Polynomial"] + write_utils.export_dataframe_die_on_diff(qc_df, file_name, "model comparision", float_format="%.6e") + + +def write_models( + file_name: str, linear_model: Model, poly_model: Model, groups: Sequence[metob.Group], atlas: metob.Atlas +) -> None: + """ + inputs: + file_name: text file to save model information + linear_model: instance of class Model with first order model + poly_model: instance of class Model with second order model + groups: list of groups used in model generation + atlas: QC atlas + """ + with open(file_name, "w", encoding="utf8") as out_fh: + for model in [linear_model, poly_model]: + out_fh.write(f"{model.sk_model.set_params()}\n") + out_fh.write(f"{model}\n") + group_names = ", ".join([g.name for g in groups]) + out_fh.write(f"groups = {group_names}\n") + out_fh.write(f"atlas = {atlas.name}\n\n") + + +def get_atlas_name(template_name: str, model: Model, project_id: str, analysis_name: str) -> str: + """ + input: + template_name: name of template atlas + ids: an AnalysisIds object matching the one used in the main notebook + model: an instance of Model + returns the name of the production atlas + """ + prod_name = template_name.replace("TPL", "PRD") + return f"{prod_name}_{model.name}_{project_id}_{analysis_name}" + + +def align_atlas(atlas: metob.Atlas, model: Model, ids: AnalysisIdentifiers) -> pd.DataFrame: + """use model to align RTs within atlas""" + atlas_df = ma_data.make_atlas_df(atlas) + atlas_df["label"] = [cid.name for cid in atlas.compound_identifications] + atlas_df["rt_peak"] = model.predict(atlas_df["rt_peak"].to_numpy()) + rt_offset = 0.2 if ids.chromatography == "C18" else 0.5 + atlas_df["rt_min"] = atlas_df["rt_peak"].apply(lambda rt: rt - rt_offset) + atlas_df["rt_max"] = atlas_df["rt_peak"].apply(lambda rt: rt + rt_offset) + return atlas_df + + +def create_aligned_atlases( + linear: Model, + poly: Model, + ids: AnalysisIdentifiers, + workflow: Workflow, +) -> List[str]: + """ + input: + linear_model: instance of class Model with first order model + poly_model: instance of class Model with second order model + ids: an AnalysisIdentifiers object + workflow: a config Workflow object + returns a list of the names of atlases + """ + # pylint: disable=too-many-locals + out_atlas_names = [] + model = poly if workflow.rt_alignment.parameters.use_poly_model else linear + for analysis in tqdm(workflow.analyses, unit="atlas"): + template_atlas = get_atlas(analysis.atlas.name, analysis.atlas.username) + if analysis.atlas.do_alignment: + out_atlas_names.append(get_atlas_name(template_atlas.name, model, ids.project, analysis.name)) + logger.info("Creating atlas %s", out_atlas_names[-1]) + out_atlas_file_name = os.path.join(ids.output_dir, f"{out_atlas_names[-1]}.csv") + out_atlas_df = align_atlas(template_atlas, model, ids) + write_utils.export_dataframe_die_on_diff( + out_atlas_df, out_atlas_file_name, "RT aligned atlas", index=False, float_format="%.6e" + ) + dp.make_atlas_from_spreadsheet( + out_atlas_df, + out_atlas_names[-1], + filetype="dataframe", + sheetname="", + polarity=analysis.parameters.polarity, + store=True, + mz_tolerance=10 if ids.chromatography == "C18" else 12, + ) + else: + out_atlas_names.append(template_atlas.name) + return out_atlas_names + + +def write_notebooks(ids: AnalysisIdentifiers, atlases: Sequence[str], workflow: Workflow) -> List[Path]: + """ + Creates Targeted analysis jupyter notebooks with pre-populated parameter sets + Inputs: + ids: an AnalysisIds object matching the one used in the main notebook + workflow: a Workflow object + atlases: list of atlas names to use as source atlases + Returns a list of Paths to notebooks + """ + out = [] + for atlas_name, analysis in zip(atlases, workflow.analyses): + source = repo_path() / "notebooks" / "reference" / "Targeted.ipynb" + dest = Path(ids.output_dir).resolve().parent / f"{ids.project}_{workflow.name}_{analysis.name}.ipynb" + parameters = { + "experiment": ids.experiment, + "rt_alignment_number": ids.rt_alignment_number, + "analysis_number": 0, + "workflow_name": workflow.name, + "analysis_name": analysis.name, + "source_atlas": atlas_name, + "copy_atlas": analysis.parameters.copy_atlas, + "polarity": analysis.parameters.polarity, + "include_groups": analysis.parameters.include_groups, + "exclude_groups": analysis.parameters.exclude_groups, + "groups_controlled_vocab": analysis.parameters.groups_controlled_vocab, + "exclude_files": analysis.parameters.exclude_files, + "generate_qc_outputs": analysis.parameters.generate_qc_outputs, + "num_points": analysis.parameters.num_points, + "peak_height": analysis.parameters.peak_height, + "msms_score": analysis.parameters.msms_score, + "filter_removed": analysis.parameters.filter_removed, + "line_colors": analysis.parameters.line_colors, + "require_all_evaluated": analysis.parameters.require_all_evaluated, + "generate_analysis_outputs": analysis.parameters.generate_analysis_outputs, + "exclude_groups_for_analysis_outputs": analysis.parameters.exclude_groups_for_analysis_outputs, + "export_msms_fragment_ions": analysis.parameters.export_msms_fragment_ions, + "clear_cache": analysis.parameters.clear_cache, + "config_file_name": analysis.parameters.config_file_name, + "source_code_version_id": analysis.parameters.source_code_version_id, + "project_directory": or_default(analysis.parameters.project_directory, ids.project_directory), + "google_folder": or_default( + analysis.parameters.google_folder, workflow.rt_alignment.parameters.google_folder + ), + "max_cpus": analysis.parameters.max_cpus, + "log_level": analysis.parameters.log_level, + } + notebook.create_notebook(source, dest, parameters) + out.append(dest) + return out + + +def run( + experiment: str, + rt_alignment_number: int, + configuration: Config, + workflow: Workflow, +) -> MetatlasDataset: + """Generates RT alignment model, applies to atlases, and generates all outputs""" + params = workflow.rt_alignment.parameters + ids = AnalysisIdentifiers( + analysis_number=0, + source_atlas=workflow.rt_alignment.atlas.name, + source_atlas_username=workflow.rt_alignment.atlas.username, + copy_atlas=params.copy_atlas, + experiment=experiment, + project_directory=params.project_directory, + google_folder=params.google_folder, + rt_alignment_number=rt_alignment_number, + exclude_files=params.exclude_files, + include_groups=params.include_groups, + exclude_groups=params.exclude_groups, + groups_controlled_vocab=params.groups_controlled_vocab, + configuration=configuration, + workflow=workflow.name, + ) + metatlas_dataset = MetatlasDataset(ids=ids, max_cpus=params.max_cpus) + generate_outputs(metatlas_dataset, workflow) + return metatlas_dataset diff --git a/metatlas/tools/config.py b/metatlas/tools/config.py new file mode 100644 index 00000000..89157d72 --- /dev/null +++ b/metatlas/tools/config.py @@ -0,0 +1,198 @@ +"""Manage configuration options using a YAML file""" +# pylint: disable=too-few-public-methods + +import getpass +import os + +from pathlib import Path +from string import ascii_letters, digits +from typing import Dict, List, Optional, Sequence, Tuple, TypeVar + +import yaml + +from pydantic import BaseModel, validator + +from metatlas.datastructures.id_types import Polarity +from metatlas.tools.util import or_default + +ALLOWED_NAME_CHARS = ascii_letters + digits + "-" + + +class BaseNotebookParameters(BaseModel): + """Parameters common to both RT_Alignment and Targeted notebooks""" + + copy_atlas: bool = False + source_atlas: Optional[str] = None + include_groups: Optional[List[str]] = None + exclude_groups: List[str] = [] + exclude_files: List[str] = [] + groups_controlled_vocab: List[str] = [] + rt_min_delta: Optional[float] = None + rt_max_delta: Optional[float] = None + config_file_name: Optional[str] = None + source_code_version_id: Optional[str] = None + project_directory: str = str(Path().home()) + google_folder: Optional[str] = None + max_cpus: int = 4 + log_level: str = "INFO" + + +class AnalysisNotebookParameters(BaseNotebookParameters): + """Parameters for Targeted notebooks""" + + polarity: Polarity = Polarity("positive") + generate_qc_outputs: bool = False + num_points: Optional[int] = None + peak_height: Optional[float] = None + msms_score: Optional[float] = None + filter_removed: bool = False + line_colors: List[Tuple[str, str]] = [] + require_all_evaluated: bool = False + generate_analysis_outputs: bool = False + exclude_groups_for_analysis_outputs: List[str] = [] + export_msms_fragment_ions: bool = False + clear_cache: bool = False + + +class RTAlignmentNotebookParameters(BaseNotebookParameters): + """Parameters used in RT Alignment notebooks""" + + inchi_keys_not_in_model: List[str] = [] + dependent_data_source: str = "median" + use_poly_model: bool = False + stop_before: Optional[str] = None + + +class Atlas(BaseModel): + name: str + username: str + do_alignment: bool = False + + +class RTAlignment(BaseModel): + name: str = "RT-Alignment" + atlas: Atlas + parameters: RTAlignmentNotebookParameters + + @validator("name") + @classmethod + def allowed_name_chars(cls, to_check): + """Only contains letters, digits and dashes""" + return validate_allowed_chars("RT alignment name", to_check) + + +class Analysis(BaseModel): + name: str + atlas: Atlas + parameters: AnalysisNotebookParameters + + @validator("name") + @classmethod + def allowed_name_chars(cls, to_check): + """Only contains letters, digits and dashes""" + return validate_allowed_chars("analysis names", to_check) + + +class Workflow(BaseModel): + name: str + rt_alignment: RTAlignment + analyses: List[Analysis] + + @validator("name") + @classmethod + def allowed_name_chars(cls, to_check): + """Only contains letters, digits and dashes""" + return validate_allowed_chars("workflow names", to_check) + + def get_analysis(self, analysis_name: str) -> Analysis: + """Returns Analysis with analysis_name or ValueError""" + for analysis in self.analyses: + if analysis.name == analysis_name: + return analysis + raise ValueError(f"Analysis named '{analysis_name}' was not found within the workflow.") + + +class Chromatography(BaseModel): + name: str + aliases: List[str] = [] + + @validator("name") + @classmethod + def allowed_name_chars(cls, to_check): + """Only contains letters, digits and dashes""" + return validate_allowed_chars("chromatography names", to_check) + + +class Config(BaseModel): + chromatography_types: List[Chromatography] = [] + workflows: List[Workflow] + + @validator("workflows") + @classmethod + def unique_workflow_names(cls, to_check): + """Do not allow duplicated workflow names""" + dup_workflow_names = get_dups([w.name for w in to_check]) + if dup_workflow_names: + raise ValueError(f"Workflow names were redefined: {', '.join(dup_workflow_names)}.") + return to_check + + def get_workflow(self, workflow_name: str) -> Workflow: + """Returns workflow with workflow_name or ValueError""" + for workflow in self.workflows: + if workflow.name == workflow_name: + return workflow + raise ValueError(f"Workflow named '{workflow_name}' was not found in the configuration file.") + + def update(self, override_parameters: Dict) -> None: + """update all parameters within self.workflows with any non-None values in override_parameters""" + for flow in self.workflows: + for analysis in [flow.rt_alignment] + flow.analyses: + if analysis.parameters.source_atlas is not None: + analysis.atlas.name = analysis.parameters.source_atlas + analysis.atlas.username = getpass.getuser() + for name in analysis.parameters.__dict__.keys(): + if name in override_parameters and override_parameters[name] is not None: + setattr(analysis.parameters, name, override_parameters[name]) + analysis.parameters.google_folder = or_default( + analysis.parameters.google_folder, flow.rt_alignment.parameters.google_folder + ) + + +def load_config(file_name: os.PathLike) -> Config: + """Return configuration from file""" + with open(file_name, "r", encoding="utf-8") as yaml_fh: + return Config.parse_obj(yaml.safe_load(yaml_fh)) + + +def get_config(override_parameters: Dict) -> Tuple[Config, Workflow, Analysis]: + """loads configuration from file and updates workflow parameters with override_parameters""" + config = load_config(override_parameters["config_file_name"]) + config.update(override_parameters) + workflow = config.get_workflow(override_parameters["workflow_name"]) + if "analysis_name" in override_parameters: + analysis = workflow.get_analysis(override_parameters["analysis_name"]) + else: + analysis = workflow.rt_alignment + return (config, workflow, analysis) + + +Generic = TypeVar("Generic") + + +def get_dups(seq: Sequence[Generic]) -> List[Generic]: + """Returns each non-unique value from seq""" + seen = set() + dupes = [] + for value in seq: + if value in seen: + dupes.append(value) + else: + seen.add(value) + return dupes + + +def validate_allowed_chars(variable_name, to_check): + """returns to_check if valid, otherwise raises ValueError""" + if any(c not in ALLOWED_NAME_CHARS for c in to_check): + raise ValueError(f"Only letters, numbers, and '-' are allowed in {variable_name}.") + return to_check diff --git a/metatlas/tools/fastanalysis.py b/metatlas/tools/fastanalysis.py index ac26861e..3d913c98 100644 --- a/metatlas/tools/fastanalysis.py +++ b/metatlas/tools/fastanalysis.py @@ -61,6 +61,7 @@ def make_stats_table(input_fname = '', input_dataset = [], msms_hits_df = None, metatlas_dataset = input_dataset dataset = dp.filter_runs(metatlas_dataset, include_lcmsruns, include_groups, exclude_lcmsruns, exclude_groups) + assert len(dataset) > 0 metrics = ['msms_score', 'num_frag_matches', 'mz_centroid', 'mz_ppm', 'rt_peak', 'rt_delta', 'peak_height', 'peak_area', 'num_data_points'] ds_dir = os.path.join(output_loc, 'data_sheets') if data_sheets else "" diff --git a/metatlas/tools/notebook.py b/metatlas/tools/notebook.py index cfb37918..42db7b7c 100644 --- a/metatlas/tools/notebook.py +++ b/metatlas/tools/notebook.py @@ -19,6 +19,8 @@ logger = logging.getLogger(__name__) +PAPERMILL_EXEC_ENV = "PAPERMILL_EXECUTION" + def configure_environment(log_level: str) -> None: """ @@ -150,3 +152,11 @@ def assignment_string(lhs: str, rhs: Any) -> str: else: rhs_str = json.dumps(rhs) return f"{lhs} = {rhs_str}\n" + + +def in_papermill(): + """ + Returns True if notebook is running in papermill. + Requires setting of envionmental variable before running papermill! + """ + return PAPERMILL_EXEC_ENV in os.environ diff --git a/metatlas/tools/predict_rt.py b/metatlas/tools/predict_rt.py deleted file mode 100644 index c3e24921..00000000 --- a/metatlas/tools/predict_rt.py +++ /dev/null @@ -1,957 +0,0 @@ -"""Generate Retention Time Correction Model""" -# pylint: disable=too-many-arguments - -import logging -import math -import os - -from copy import deepcopy -from datetime import datetime -from pathlib import Path -from typing import List, Optional, Tuple, Sequence - -import matplotlib.pyplot as plt -import matplotlib.ticker as mticker -import numpy as np -import pandas as pd - -from matplotlib import gridspec -from matplotlib.axis import Axis -from sklearn.base import BaseEstimator -from sklearn.linear_model import LinearRegression, RANSACRegressor -from sklearn.preprocessing import PolynomialFeatures -from tqdm.notebook import tqdm - -from metatlas.datastructures.id_types import Polarity -from metatlas.datastructures import metatlas_dataset as mads -from metatlas.datastructures.analysis_identifiers import AnalysisIdentifiers, MSMS_REFS_PATH -from metatlas.datastructures import metatlas_objects as metob -from metatlas.io import metatlas_get_data_helper_fun as ma_data -from metatlas.io import targeted_output -from metatlas.io import write_utils -from metatlas.plots import dill2plots as dp -from metatlas.tools import notebook -from metatlas.tools import parallel - -logger = logging.getLogger(__name__) - -# metatlas_dataset type that isn't an instance of the MetatlasDataset class -SimpleMetatlasData = List[List[dict]] - -TEMPLATES = { - "positive": { - "HILIC": [ - {"name": "HILICz150_ANT20190824_TPL_EMA_Unlab_POS", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_QCv3_Unlab_POS", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_ISv5_Unlab_POS", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_ISv5_13C15N_POS", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_IS_LabUnlab2_POS", "username": "vrsingan"}, - ], - "C18": [ - {"name": "C18_20220215_TPL_IS_Unlab_POS", "username": "wjholtz"}, - {"name": "C18_20220531_TPL_EMA_Unlab_POS", "username": "wjholtz"}, - ], - }, - "negative": { - "HILIC": [ - {"name": "HILICz150_ANT20190824_TPL_EMA_Unlab_NEG", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_QCv3_Unlab_NEG", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_ISv5_Unlab_NEG", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_ISv5_13C15N_NEG", "username": "vrsingan"}, - {"name": "HILICz150_ANT20190824_TPL_IS_LabUnlab2_NEG", "username": "vrsingan"}, - ], - "C18": [ - {"name": "C18_20220215_TPL_IS_Unlab_NEG", "username": "wjholtz"}, - {"name": "C18_20220531_TPL_EMA_Unlab_NEG", "username": "wjholtz"}, - ], - }, -} - -QC_ATLASES = { - "positive": { - "HILIC": {"name": "HILICz150_ANT20190824_TPL_QCv3_Unlab_POS", "username": "vrsingan"}, - "C18": {"name": "C18_20220215_TPL_IS_Unlab_POS", "username": "wjholtz"}, - }, - "negative": { - "HILIC": {"name": "HILICz150_ANT20190824_TPL_QCv3_Unlab_NEG", "username": "vrsingan"}, - "C18": {"name": "C18_20220215_TPL_IS_Unlab_NEG", "username": "wjholtz"}, - }, -} - - -class Model: - """Encapsulate both linear and polynomial models in a consistent interface""" - - def __init__(self, sk_model: BaseEstimator, intercept: float, coefficents: np.ndarray): - """ - inputs: - sk_model: scikit-learn model object - intercept: y-intercept value - coefficents: a list of coefficents, with x^n coefficent at index n-1 - """ - self.sk_model = sk_model - self.intercept = intercept - if coefficents.shape == (1, 1): - self.coefficents = [intercept, coefficents[0][0]] - elif coefficents.shape == (1, 3): - self.coefficents = coefficents[0].tolist() - - def __repr__(self) -> str: - """Text description of the model function""" - if self.order == 1: - return f"Linear model with intercept={self.intercept:.3f} and slope={self.coefficents[1]:.5f}" - coef_str = ", ".join([f"{c:.5f}" for c in self.coefficents]) - return f"Polynomial model with intercept={self.intercept:.3f} and coefficents=[{coef_str}]" - - @property - def order(self) -> int: - """Polynomial order of the model""" - return len(self.coefficents) - 1 - - @property - def name(self) -> str: - """Type of model as string""" - return "linear" if self.order == 1 else "polynomial" - - def predict(self, x_values) -> List[float]: - """Returns y values for input x""" - x_transformed = x_values.reshape(-1, 1) - if self.order > 1: - poly_reg = PolynomialFeatures(degree=2) - x_transformed = poly_reg.fit_transform(x_transformed) - return self.sk_model.predict(x_transformed).flatten().tolist() - - -def generate_rt_correction_models( - ids: AnalysisIdentifiers, - metatlas_dataset: SimpleMetatlasData, - groups: Sequence[metob.Group], - qc_atlas: metob.Atlas, - qc_atlas_df: pd.DataFrame, - selected_col: str, - inchi_keys_not_in_model: Optional[List[str]] = None, -) -> Tuple[Model, Model]: - """ - Generate the RT correction models and model charaterization files - inputs: - ids: an AnalysisIds object matching the one selected_cold in the main notebook - selected_col: name of column to use for model generation - inchi_keys_not_in_model: InChi Keys that will be ignored when for model creation - Returns a tuple with a linear and polynomial model - """ - # pylint: disable=too-many-locals - rts_df = get_rts(metatlas_dataset) - actual, pred = subset_data_for_model_input(selected_col, rts_df, qc_atlas_df, inchi_keys_not_in_model) - linear, poly = generate_models(actual, pred) - out_dir = Path(ids.output_dir).parent - actual_rts, pred_rts = actual_and_predicted_rts(rts_df, qc_atlas_df, inchi_keys_not_in_model) - actual_vs_pred_file_name = out_dir / "Actual_vs_Predicted_RTs.pdf" - plot_actual_vs_pred_rts(pred_rts, actual_rts, rts_df, str(actual_vs_pred_file_name), linear, poly) - rt_comparison_file_name = out_dir / "RT_Predicted_Model_Comparison.csv" - save_model_comparison(selected_col, qc_atlas_df, rts_df, linear, poly, str(rt_comparison_file_name)) - models_file_name = out_dir / "rt_model.txt" - write_models(str(models_file_name), linear, poly, groups, qc_atlas) - return (linear, poly) - - -def generate_outputs( - ids: AnalysisIdentifiers, - cpus: int, - num_points: Optional[int] = None, - peak_height: Optional[float] = None, - msms_score: Optional[float] = None, - use_poly_model: bool = True, - model_only: bool = False, - selected_col: str = "median", - stop_before: Optional[str] = None, - source_code_version_id: Optional[str] = None, - rt_min_delta: Optional[float] = None, - rt_max_delta: Optional[float] = None, - inchi_keys_not_in_model: Optional[List[str]] = None, -) -> None: - """ - Generate the RT correction models, associated atlases with adjusted RT values, follow up notebooks, - msms hits pickles - inputs: - ids: an AnalysisIds object matching the one used in the main notebook - cpus: max number of cpus to use - num_points: minimum number of data points in a peak - peak_height: threshold intensity level for filtering - msms_score: minimum spectra similarity score to pass filtering - use_poly_model: If True, use the polynomial model, else use linear model - Both types of models are always generated, this only determines which ones - are pre-populated into the generated notebooks - model_only: Setting to true is equivalent to stop_before=qc_plots - selected_col: name of column to use for model generation - stop_before: one of None, qc_plots, atlases, notebooks, msms_hits - stop before generating this output and all following outputs - ISTDEtc QC plots are only generated if step_before is None - source_code_version_id: pass through parameter to downstream notebooks - rt_min_delta: added to atlas' rt_peak to generate rt_min, None uses atlas value for rt_min - rt_max_delta: added to atlas' rt_peak to generate rt_max, None uses atlas value for rt_max - inchi_keys_not_in_model: InChi Keys that will be ignored when for model creation - """ - # pylint: disable=too-many-locals - stop_before = "qc_plots" if model_only else stop_before - assert stop_before in ["qc_plots", "atlases", "notebooks", "msms_hits", None] - metatlas_dataset, groups, atlas, atlas_df = load_data(ids, cpus, rt_min_delta, rt_max_delta) - linear, poly = generate_rt_correction_models( - ids, metatlas_dataset, groups, atlas, atlas_df, selected_col, inchi_keys_not_in_model - ) - if stop_before in ["atlases", "notebooks", "msms_hits", None]: - alt_ids = get_analysis_ids_for_rt_prediction( - ids.experiment, - ids.project_directory, - ids.google_folder, - ids.rt_predict_number, - Polarity("negative" if ids.polarity == "positive" else "positive"), - ids.exclude_files, - ids.include_groups, - ids.exclude_groups, - ids.groups_controlled_vocab, - ) - alt_metatlas_dataset, _, _, _ = load_data(alt_ids, cpus, rt_min_delta, rt_max_delta) - generate_qc_outputs(metatlas_dataset, ids, cpus) - generate_qc_outputs(alt_metatlas_dataset, alt_ids, cpus) - if stop_before in ["notebooks", "msms_hits", None]: - atlases = create_adjusted_atlases(linear, poly, ids) - if stop_before in ["msms_hits", None]: - write_notebooks( - ids, atlases, use_poly_model, num_points, peak_height, msms_score, source_code_version_id - ) - if stop_before is None: - pre_process_data_for_all_notebooks( - ids, atlases, cpus, use_poly_model, num_points, peak_height, msms_score - ) - targeted_output.copy_outputs_to_google_drive(ids) - targeted_output.archive_outputs(ids) - logger.info("RT correction notebook complete. Switch to Targeted notebook to continue.") - - -def generate_qc_plots(metatlas_dataset: SimpleMetatlasData, ids: AnalysisIdentifiers) -> None: - """Write plots that can be used to QC the experiment""" - rts_df = get_rts(metatlas_dataset) - compound_atlas_rts_file_name = os.path.join( - ids.output_dir, f"{ids.short_polarity}_Compound_Atlas_RTs.pdf" - ) - plot_compound_atlas_rts(len(metatlas_dataset), rts_df, compound_atlas_rts_file_name) - peak_heights_df = get_peak_heights(metatlas_dataset) - peak_heights_plot_file_name = os.path.join( - ids.output_dir, f"{ids.short_polarity}_Compound_Atlas_peak_heights.pdf" - ) - plot_compound_atlas_peak_heights(len(metatlas_dataset), peak_heights_df, peak_heights_plot_file_name) - - -def generate_qc_outputs(metatlas_dataset: SimpleMetatlasData, ids: AnalysisIdentifiers, cpus: int) -> None: - """Write outputs that can be used to QC the experiment""" - save_rt_peak(metatlas_dataset, os.path.join(ids.output_dir, f"{ids.short_polarity}_rt_peak.tab")) - save_measured_rts( - metatlas_dataset, os.path.join(ids.output_dir, f"{ids.short_polarity}_QC_Measured_RTs.csv") - ) - generate_qc_plots(metatlas_dataset, ids) - write_chromatograms(metatlas_dataset, ids.output_dir, max_cpus=cpus) - hits = dp.get_msms_hits(metatlas_dataset, extra_time=0.2, ref_loc=MSMS_REFS_PATH) - write_identification_figures( - metatlas_dataset, hits, ids.output_dir, ids.lcmsruns_short_names, prefix=ids.short_polarity - ) - - -def load_data( - ids: AnalysisIdentifiers, - cpus: int, - rt_min_delta: Optional[float], - rt_max_delta: Optional[float], -) -> Tuple[SimpleMetatlasData, List[metob.Group], metob.Atlas, pd.DataFrame]: - """create metatlas_dataset, groups and atlas""" - groups = get_groups(ids) - files_df = get_files_df(groups) - qc_atlas, qc_atlas_df = get_qc_atlas(ids, rt_min_delta, rt_max_delta) - # this metatlas_dataset is not a class instance. Only has metatlas_dataset[file_idx][compound_idx]... - metatlas_dataset = load_runs(files_df, qc_atlas_df, qc_atlas, cpus) - try: - if len(metatlas_dataset) == 0: - raise ValueError("No matching LCMS runs, terminating without generating outputs.") - except ValueError as err: - logger.exception(err) - raise err - return metatlas_dataset, groups, qc_atlas, qc_atlas_df - - -def write_identification_figures( - data: SimpleMetatlasData, - hits: pd.DataFrame, - output_dir: str, - run_short_names: pd.DataFrame, - overwrite: bool = False, - prefix: str = "", -) -> None: - """ - inputs: - data: a metatlas_dataset datastructures (does not need to be MetatlasDataset class) - hits: msms hits - output_dir: directory to write pdfs to - run_short_names: short names for LCMS runs in a dataframe - overwrite: if False, throw error if files already exist - """ - dp.make_identification_figure_v2( - input_dataset=data, - msms_hits=hits, - use_labels=True, - include_lcmsruns=["QC"], - exclude_lcmsruns=[], - output_loc=output_dir, - short_names_df=run_short_names, - polarity=prefix, - overwrite=overwrite, - ) - - -def pre_process_data_for_all_notebooks( - ids: AnalysisIdentifiers, - atlases: Sequence[str], - cpus: int, - use_poly_model: bool, - num_points: Optional[int], - peak_height: Optional[float], - msms_score: Optional[float], -) -> None: - """ - inputs: - ids: an AnalysisIds object matching the one used in the main notebook - atlases: list of atlas names to consider generating hits for - cpus: max number of cpus to use - use_poly_model: If True, use the polynomial model, else use linear model - Both types of models are always generated, this only determines which ones - are pre-populated into the generated notebooks - num_points: minimum number of data points in a peak - peak_height: threshold intensity level for filtering - msms_score: minimum spectra similarity score to pass filtering - Calls MetatlasDataset().hits, which will create a hits cache file - Filters compounds by signal strength to reduce atlas size - """ - for atlas_name in atlases: - if (use_poly_model and "linear" in atlas_name) or (not use_poly_model and "polynomial" in atlas_name): - continue - current_ids = AnalysisIdentifiers( - source_atlas=atlas_name, - experiment=ids.experiment, - output_type=get_output_type(ids.chromatography, atlas_name), - polarity="positive" if "_POS_" in atlas_name else "negative", - analysis_number=ids.analysis_number, - project_directory=ids.project_directory, - google_folder=ids.google_folder, - rt_predict_number=ids.rt_predict_number, - ) - metatlas_dataset = mads.MetatlasDataset(ids=current_ids, max_cpus=cpus) - if current_ids.output_type == "ISTDsEtc": - generate_qc_plots(metatlas_dataset, current_ids) - _ = metatlas_dataset.hits - if "EMA" in current_ids.output_type: - metatlas_dataset.filter_compounds_by_signal(num_points, peak_height, msms_score) - - -def get_output_type(chromatography: str, atlas_name: str) -> str: - """Returns an output type string""" - return f"FinalEMA-{chromatography}" if "EMA" in atlas_name else "ISTDsEtc" - - -def get_groups(ids: AnalysisIdentifiers) -> List[metob.Group]: - """ - Create all experiment groups if they don't already exist and return the subset matching include_list - inputs: - ids: instance of AnalysisIds - """ - ordered_groups = sorted(ids.groups, key=lambda x: x.name) - for grp in ordered_groups: - logger.info("Selected group: %s, %s", grp.name, int_to_date_str(grp.last_modified)) - return ordered_groups - - -def int_to_date_str(i_time: int) -> str: - """unix epoc time in seconds to YYYY-MM-DD hh:mm:ss""" - return str(datetime.fromtimestamp(i_time)) - - -def get_files_df(groups: Sequence[metob.Group]) -> pd.DataFrame: - """Pandas Datafram with one row per file plus columns for accquistion_time and group name""" - files_df = pd.DataFrame(columns=["file", "time", "group"]) - for group in groups: - for run in group.items: - try: - time = run.accquistion_time - except AttributeError: - time = 0 - files_df = files_df.append({"file": run, "time": time, "group": group}, ignore_index=True) - return files_df.sort_values(by=["time"]) - - -def get_qc_atlas( - ids: AnalysisIdentifiers, rt_min_delta: Optional[float], rt_max_delta: Optional[float] -) -> Tuple[metob.Atlas, pd.DataFrame]: - """Retreives template QC atlas and return tuple (atlas, atlas_df)""" - qc_atlas_dict = QC_ATLASES[ids.polarity][ids.chromatography] - qc_atlas_name = qc_atlas_dict["name"] - username = qc_atlas_dict["username"] - logger.info("Loading QC Atlas %s", qc_atlas_name) - original_atlas = metob.retrieve("Atlas", name=qc_atlas_name, username=username)[0] - atlas = adjust_atlas_rt_range(original_atlas, rt_min_delta, rt_max_delta) - atlas_df = ma_data.make_atlas_df(atlas) - atlas_df["label"] = [cid.name for cid in atlas.compound_identifications] - return atlas, atlas_df - - -def adjust_atlas_rt_range( - in_atlas: metob.Atlas, rt_min_delta: Optional[float], rt_max_delta: Optional[float] -) -> metob.Atlas: - """Reset the rt_min and rt_max values by adding rt_min_delta or rt_max_delta to rt_peak""" - if rt_min_delta is None and rt_max_delta is None: - return in_atlas - out_atlas = deepcopy(in_atlas) - for cid in out_atlas.compound_identifications: - rts = cid.rt_references[0] - rts.rt_min = rts.rt_min if rt_min_delta is None else rts.rt_peak + rt_min_delta - rts.rt_max = rts.rt_max if rt_max_delta is None else rts.rt_peak + rt_max_delta - return out_atlas - - -def load_runs( - files_df: pd.DataFrame, qc_atlas_df: pd.DataFrame, qc_atlas: metob.Atlas, cpus: int -) -> SimpleMetatlasData: - """ - Loads MSMS data file files - inputs: - files_df: files to load - qc_atlas_df: dataframe form of the QC atlas - qc_atlas: atlas of QC compounds - cpus: number of cpus to use - """ - files = [(i[1].file, i[1].group, qc_atlas_df, qc_atlas) for i in files_df.iterrows()] - logger.info("Loading LCMS data files") - return parallel.parallel_process(ma_data.get_data_for_atlas_df_and_file, files, cpus, unit="sample") - - -def save_measured_rts(metatlas_dataset: SimpleMetatlasData, file_name: str) -> None: - """Save RT values in csv format file""" - rts_df = get_rts(metatlas_dataset, include_atlas_rt_peak=False) - write_utils.export_dataframe_die_on_diff(rts_df, file_name, "measured RT values", float_format="%.6e") - - -def save_rt_peak(metatlas_dataset: SimpleMetatlasData, file_name: str) -> None: - """Save peak RT values in tsv format file""" - rts_df = dp.make_output_dataframe(input_dataset=metatlas_dataset, fieldname="rt_peak", use_labels=True) - write_utils.export_dataframe_die_on_diff( - rts_df, file_name, "peak RT values", sep="\t", float_format="%.6e" - ) - - -def get_rts(metatlas_dataset: SimpleMetatlasData, include_atlas_rt_peak: bool = True) -> pd.DataFrame: - """Returns RT values in DataFrame format""" - rts_df = dp.make_output_dataframe( - input_dataset=metatlas_dataset, - fieldname="rt_peak", - use_labels=True, - summarize=True, - ) - if include_atlas_rt_peak: - rts_df["atlas RT peak"] = [ - compound["identification"].rt_references[0].rt_peak for compound in metatlas_dataset[0] - ] - return order_df_columns_by_run(rts_df) - - -def get_peak_heights(metatlas_dataset: SimpleMetatlasData) -> pd.DataFrame: - """Returns peak heights in DataFrame format""" - peak_height_df = dp.make_output_dataframe( - input_dataset=metatlas_dataset, - fieldname="peak_height", - use_labels=True, - summarize=True, - ) - return order_df_columns_by_run(peak_height_df) - - -def order_df_columns_by_run(dataframe: pd.DataFrame) -> pd.DataFrame: - """ - Returns a dataframe with re-ordered columns such that second column up to column 'mean' - are ordered by run number from low to high - """ - cols = dataframe.columns.tolist() - stats_start_idx = cols.index("mean") - to_sort = cols[:stats_start_idx] - no_sort = cols[stats_start_idx:] - to_sort.sort( - key=lambda x: int( - x.split(".")[0].split("_")[-1].lower().replace("run", "").replace("seq", "").replace("s", "") - ) - ) - new_cols = to_sort + no_sort - return dataframe[new_cols] - - -def plot_per_compound( - field_name: str, - num_files: int, - data: pd.DataFrame, - file_name: str, - fontsize: float = 2, - pad: float = 0.1, - cols: int = 8, -) -> None: - """ - Writes plot of RT peak for vs file for each compound - inputs: - field_name: one of rt_peak or peak_height - num_files: number of files in data set, ie len(metatlas_dataset) - data: Dataframe with RTs values - file_name: where to save plot - fontsize: size of text - pad: padding size - cols: number of columns in plot grid - """ - logger.info("Plotting %s vs file for each compound", field_name) - plot_df = ( - data.sort_values(by="standard deviation", ascending=False, na_position="last") - .drop(["#NaNs"], axis=1) - .dropna(axis=0, how="all", subset=data.columns[:num_files]) - ) - rows = int(math.ceil((data.shape[0] + 1) / cols)) - fig = plt.figure() - grid = gridspec.GridSpec(rows, cols, figure=fig, wspace=0.2, hspace=0.4) - for i, (_, row) in tqdm(enumerate(plot_df.iterrows()), total=len(plot_df), unit="plot"): - a_x = fig.add_subplot(grid[i]) - range_columns = list(plot_df.columns[:num_files]) - file_vs_value_plot(a_x, field_name, row, range_columns, fontsize, pad) - plt.savefig(file_name, bbox_inches="tight") - plt.close() - - -def file_vs_value_plot( - a_x: Axis, field_name: str, row: pd.DataFrame, range_columns: List[str], fontsize: float, pad: float -) -> None: - """Create a dot plot with one point per file""" - assert field_name in ["rt_peak", "peak_height"] - a_x.tick_params(direction="in", length=1, pad=pad, width=0.1, labelsize=fontsize) - num_files = len(range_columns) - a_x.scatter(range(num_files), row[:num_files], s=0.2) - if field_name == "rt_peak": - a_x.axhline(y=row["atlas RT peak"], color="r", linestyle="-", linewidth=0.2) - range_columns += ["atlas RT peak"] - a_x.set_ylim(np.nanmin(row.loc[range_columns]) - 0.12, np.nanmax(row.loc[range_columns]) + 0.12) - else: - a_x.set_yscale("log") - a_x.set_ylim(bottom=1e4, top=1e10) - a_x.set_xlim(-0.5, num_files + 0.5) - a_x.xaxis.set_major_locator(mticker.FixedLocator(np.arange(0, num_files, 1.0))) - _ = [s.set_linewidth(0.1) for s in a_x.spines.values()] - # truncate name so it fits above a single subplot - a_x.set_title(row.name[:33], pad=pad, fontsize=fontsize) - a_x.set_xlabel("Files", labelpad=pad, fontsize=fontsize) - ylabel = "Actual RTs" if field_name == "rt_peak" else "Peak Height" - a_x.set_ylabel(ylabel, labelpad=pad, fontsize=fontsize) - - -def plot_compound_atlas_rts( - num_files: int, rts_df: pd.DataFrame, file_name: str, fontsize: float = 2, pad: float = 0.1, cols: int = 8 -) -> None: - """Plot filenames vs peak RT for each compound""" - plot_per_compound("rt_peak", num_files, rts_df, file_name, fontsize, pad, cols) - - -def plot_compound_atlas_peak_heights( - num_files: int, - peak_heights_df: pd.DataFrame, - file_name: str, - fontsize: float = 2, - pad: float = 0.1, - cols: int = 8, -) -> None: - """Plot filenames vs peak height for each compound""" - plot_per_compound("peak_height", num_files, peak_heights_df, file_name, fontsize, pad, cols) - - -def generate_models(actual: List[float], pred: List[float]) -> Tuple[Model, Model]: - """ - inputs: - actual: experimental RTs - pred_df: predicted RTs - returns tuple containing two Model classes of order 1 and 2 - """ - transformed_actual = np.array(actual).reshape(-1, 1) - transformed_pred = np.array(pred).reshape(-1, 1) - - ransac = RANSACRegressor(random_state=42) - rt_model_linear = ransac.fit(transformed_pred, transformed_actual) - linear = Model( - rt_model_linear, rt_model_linear.estimator_.intercept_[0], rt_model_linear.estimator_.coef_ - ) - - poly_reg = PolynomialFeatures(degree=2) - x_poly = poly_reg.fit_transform(transformed_pred) - rt_model_poly = LinearRegression().fit(x_poly, transformed_actual) - poly = Model(rt_model_poly, rt_model_poly.intercept_[0], rt_model_poly.coef_) - return linear, poly - - -def subset_data_for_model_input( - selected_column: str, - rts_df: pd.DataFrame, - atlas_df: pd.DataFrame, - inchi_keys_not_in_model: Optional[List[str]] = None, -) -> Tuple[List[float], List[float]]: - """ - inputs: - selected_column: column number in rts_df to use for actual values - rts_df: dataframe of RT values - atlas_df: QC atlas in dataframe format - inchi_keys_not_in_model: InChi Keys that will be ignored for model creation - return a tuple of (actual, pred) - """ - keep_idxs = get_keep_idxs(selected_column, rts_df, atlas_df, inchi_keys_not_in_model) - actual = rts_df.iloc[keep_idxs][selected_column].tolist() - pred = atlas_df.iloc[keep_idxs]["rt_peak"].tolist() - return actual, pred - - -def get_keep_idxs( - selected_column: str, - rts_df: pd.DataFrame, - atlas_df: pd.DataFrame, - inchi_keys_not_in_model: Optional[List[str]] = None, -) -> List[int]: - """Indices in rts_df that should be used within the model""" - keep_idxs = set(np.flatnonzero(~np.isnan(rts_df.loc[:, selected_column]))) - if inchi_keys_not_in_model: - keep_idxs = keep_idxs.intersection( - set(np.flatnonzero(~atlas_df["inchi_key"].isin(inchi_keys_not_in_model))) - ) - return list(keep_idxs) - - -def actual_and_predicted_rts( - rts_df: pd.DataFrame, atlas_df: pd.DataFrame, inchi_keys_not_in_model: Optional[List[str]] = None -) -> Tuple[List[List[float]], List[List[float]]]: - """ - inputs: - rts_df: dataframe of RT values - atlas_df: QC atlas in dataframe format - inchi_keys_not_in_model: InChi Keys that will be ignored for model creation - return a tuple of lists of lists: (actual_rts, pred_rts) - """ - actual_rts = [] - pred_rts = [] - for i in range(rts_df.shape[1] - 5): - keep_idxs = get_keep_idxs(rts_df.columns[i], rts_df, atlas_df, inchi_keys_not_in_model) - current_actual_df = rts_df.loc[:, rts_df.columns[i]] - current_actual_df = current_actual_df.iloc[keep_idxs] - current_pred_df = atlas_df.iloc[keep_idxs][["rt_peak"]] - actual_rts.append(current_actual_df.values.tolist()) - pred_rts.append(current_pred_df.values.tolist()) - return actual_rts, pred_rts - - -def plot_actual_vs_pred_rts( - pred_rts: Sequence[Sequence[float]], - actual_rts: Sequence[Sequence[float]], - rts_df: pd.DataFrame, - file_name: str, - linear: Model, - poly: Model, -) -> None: - """Write scatter plot showing linear vs polynomial fit""" - # pylint: disable=too-many-locals - rows = int(math.ceil((rts_df.shape[1] + 1) / 5)) - cols = 5 - fig = plt.figure(constrained_layout=False) - grid = gridspec.GridSpec(rows, cols, figure=fig) - plt.rc("font", size=6) - plt.rc("axes", labelsize=6) - plt.rc("xtick", labelsize=3) - plt.rc("ytick", labelsize=3) - for i in range(rts_df.shape[1] - 5): - x_values = pred_rts[i] - y_values = actual_rts[i] - if len(x_values) == 0 or len(y_values) == 0: - continue - sub = fig.add_subplot(grid[i]) - sub.scatter(x_values, y_values, s=2) - spaced_x = np.linspace(0, max(x_values), 100) - sub.plot(spaced_x, linear.predict(spaced_x), linewidth=0.5, color="red") - sub.plot(spaced_x, poly.predict(spaced_x), linewidth=0.5, color="green") - sub.set_title("File: " + str(i)) - sub.set_xlabel("predicted RTs") - sub.set_ylabel("actual RTs") - fig_legend = [ - ( - "Red line: linear model; Green curve: polynomial model. " - "Default model is a polynomial model using the median data." - ), - "", - "file_index data_source", - ] + [f"{i:2d} {rts_df.columns[i]}" for i in range(rts_df.shape[1] - 5)] - fig.tight_layout(pad=0.5) - line_height = 0.03 - legend_offset = line_height * len(fig_legend) - plt.text(0, -1 * legend_offset, "\n".join(fig_legend), transform=plt.gcf().transFigure) - plt.savefig(file_name, bbox_inches="tight") - - -def save_model_comparison( - selected_column: str, - qc_atlas_df: pd.DataFrame, - rts_df: pd.DataFrame, - linear: Model, - poly: Model, - file_name: str, -) -> None: - """ - Save csv format file with per-compound comparision of linear vs polynomial models - inputs: - selected_column: column number in rts_df to use for actual values - qc_atlas_df: QC atlas in dataframe format - rts_df: dataframe with RT values - linear: instance of class Model with first order model - poly: instance of class Model with second order model - file_name: where to save the plot - """ - qc_df = rts_df[[selected_column]].copy() - qc_df.columns = ["RT Measured"] - # qc_df["RT Reference"] = qc_atlas_df["rt_peak"] - qc_df.loc[:, "RT Reference"] = qc_atlas_df["rt_peak"].to_numpy() - qc_df.loc[:, "RT Linear Pred"] = pd.Series( - linear.predict(qc_df["RT Reference"].to_numpy()), index=qc_df.index - ) - qc_df.loc[:, "RT Polynomial Pred"] = pd.Series( - poly.predict(qc_df["RT Reference"].to_numpy()), index=qc_df.index - ) - # qc_df["RT Linear Pred"] = linear.predict(qc_df["RT Reference"].to_numpy()) - # qc_df["RT Polynomial Pred"] = poly.predict(qc_df["RT Reference"].to_numpy()) - qc_df["RT Diff Linear"] = qc_df["RT Measured"] - qc_df["RT Linear Pred"] - qc_df["RT Diff Polynomial"] = qc_df["RT Measured"] - qc_df["RT Polynomial Pred"] - write_utils.export_dataframe_die_on_diff(qc_df, file_name, "model comparision", float_format="%.6e") - - -def write_models( - file_name: str, linear_model: Model, poly_model: Model, groups: Sequence[metob.Group], atlas: metob.Atlas -) -> None: - """ - inputs: - file_name: text file to save model information - linear_model: instance of class Model with first order model - poly_model: instance of class Model with second order model - groups: list of groups used in model generation - atlas: QC atlas - """ - with open(file_name, "w", encoding="utf8") as out_fh: - for model in [linear_model, poly_model]: - out_fh.write(f"{model.sk_model.set_params()}\n") - out_fh.write(f"{model}\n") - group_names = ", ".join([g.name for g in groups]) - out_fh.write(f"groups = {group_names}\n") - out_fh.write(f"atlas = {atlas.name}\n\n") - - -def get_atlas_name(template_name: str, ids: AnalysisIdentifiers, model: Model, free_text: str) -> str: - """ - input: - template_name: name of template atlas - ids: an AnalysisIds object matching the one used in the main notebook - model: an instance of Model - free_text: arbitrary string to append to atlas name - returns the name of the production atlas - """ - prod_name = template_name.replace("TPL", "PRD") - prod_atlas_name = f"{prod_name}_{model.name}_{ids.project}_{ids.analysis}" - if free_text != "": - prod_atlas_name += f"_{free_text}" - return prod_atlas_name - - -def adjust_atlas(atlas: metob.Atlas, model: Model, ids: AnalysisIdentifiers) -> pd.DataFrame: - """use model to adjust RTs within atlas""" - atlas_df = ma_data.make_atlas_df(atlas) - atlas_df["label"] = [cid.name for cid in atlas.compound_identifications] - atlas_df["rt_peak"] = model.predict(atlas_df["rt_peak"].to_numpy()) - rt_offset = 0.2 if ids.chromatography == "C18" else 0.5 - atlas_df["rt_min"] = atlas_df["rt_peak"].apply(lambda rt: rt - rt_offset) - atlas_df["rt_max"] = atlas_df["rt_peak"].apply(lambda rt: rt + rt_offset) - return atlas_df - - -def get_template_atlas(ids: AnalysisIdentifiers, polarity: Polarity, idx: int) -> metob.Atlas: - """Retreives a template atlas with the correct chromatorgraphy and polarity""" - template = TEMPLATES[polarity][ids.chromatography][idx] - return metob.retrieve("Atlas", **template)[-1] - - -def create_adjusted_atlases( - linear: Model, - poly: Model, - ids: AnalysisIdentifiers, - atlas_indices: Optional[List[int]] = None, - free_text: str = "", -) -> List[str]: - """ - input: - linear_model: instance of class Model with first order model - poly_model: instance of class Model with second order model - ids: an AnalysisIds object matching the one used in the main notebook - atlas_indices: list of integers for which adjusted atlases to create - 0: EMA_Unlab - 1: QCv3_Unlab - 2: ISv5_Unlab - 3: ISv5_13C15N - 4: IS_LabUnlab2 - free_text: arbitrary string to append to atlas name - returns a list of the names of atlases - """ - # pylint: disable=too-many-locals - assert ids.chromatography in ["HILIC", "C18"] - default_atlas_indices = [0, 1] if ids.chromatography == "C18" else [0, 4] - atlas_indices = default_atlas_indices if atlas_indices is None else atlas_indices - plot_vars = [ - (polarity, idx, model) - for polarity in ["positive", "negative"] - for idx in atlas_indices - for model in [linear, poly] - ] - out_atlas_names = [] - for polarity, idx, model in tqdm(plot_vars, unit="atlas"): - template_atlas = get_template_atlas(ids, polarity, idx) - out_atlas_names.append(get_atlas_name(template_atlas.name, ids, model, free_text)) - logger.info("Creating atlas %s", out_atlas_names[-1]) - out_atlas_file_name = os.path.join(ids.output_dir, f"{out_atlas_names[-1]}.csv") - out_atlas_df = adjust_atlas(template_atlas, model, ids) - write_utils.export_dataframe_die_on_diff( - out_atlas_df, out_atlas_file_name, "predicted atlas", index=False, float_format="%.6e" - ) - dp.make_atlas_from_spreadsheet( - out_atlas_df, - out_atlas_names[-1], - filetype="dataframe", - sheetname="", - polarity=polarity, - store=True, - mz_tolerance=10 if ids.chromatography == "C18" else 12, - ) - return out_atlas_names - - -def write_notebooks( - ids: AnalysisIdentifiers, - atlases: Sequence[str], - use_poly_model: bool, - num_points: Optional[int], - peak_height: Optional[float], - msms_score: Optional[float], - source_code_version_id: Optional[str], -) -> None: - """ - Creates Targeted analysis jupyter notebooks with pre-populated parameter sets - Inputs: - ids: an AnalysisIds object matching the one used in the main notebook - atlases: list of atlas names to use as source atlases - use_poly_model: if True use polynomial RT prediction model, else use linear model - this value is used to filter atlases from the input atlases list - num_points: pass through parameter to downstream notebooks - peak_height: pass through parameter to downstream notebooks - msms_score: pass through parameter to downstream notebooks - source_code_version_id: pass through parameter to downstream notebooks - """ - for atlas_name in atlases: - if (use_poly_model and "linear" in atlas_name) or (not use_poly_model and "polynomial" in atlas_name): - continue - polarity = "positive" if "_POS_" in atlas_name else "negative" - short_polarity = "POS" if polarity == "positive" else "NEG" - output_type = get_output_type(ids.chromatography, atlas_name) - repo_path = Path(__file__).resolve().parent.parent.parent - source = repo_path / "notebooks" / "reference" / "Targeted.ipynb" - dest = ( - Path(ids.output_dir).resolve().parent.parent - / f"{ids.project}_{output_type}_{short_polarity}.ipynb" - ) - # include_groups and exclude_groups do not get passed to subsequent notebooks - # as they need to be updated for each output type - parameters = { - "experiment": ids.experiment, - "output_type": output_type, - "polarity": polarity, - "rt_predict_number": ids.rt_predict_number, - "analysis_number": 0, - "project_directory": ids.project_directory, - "source_atlas": atlas_name, - "exclude_files": ids.exclude_files, - "groups_controlled_vocab": ids.groups_controlled_vocab, - "num_points": num_points, - "peak_height": peak_height, - "msms_score": msms_score, - "google_folder": ids.google_folder, - "source_code_version_id": source_code_version_id, - } - notebook.create_notebook(source, dest, parameters) - - -def get_analysis_ids_for_rt_prediction( - experiment: str, - project_directory: str, - google_folder: str, - rt_predict_number: int = 0, - polarity: Polarity = Polarity("positive"), # noqa: B008 - exclude_files: Optional[List[str]] = None, - include_groups: Optional[List[str]] = None, - exclude_groups: Optional[List[str]] = None, - groups_controlled_vocab: Optional[List[str]] = None, -): - """ - Simplified interface for generating an AnalysisIds instance for use in rt prediction - inputs: - experiment: name of experiment as given in LCMS run names - project_directory: directory where per-experiment output directory will be created - google_folder: id from URL of base export folder on Google Drive - rt_predict_number: integer, defaults to 0, increment if redoing RT prediction - polarity: polarity to use for RT prediction, defaults to positive - exclude_files: list of substrings that will be used to filter out lcmsruns - include_groups: list of substrings that will used to filter groups - exclude_groups list of substrings that will used to filter out groups - groups_controlled_vocab: list of substrings that will group all matches into one group - Returns an AnalysisIds instance - """ - return AnalysisIdentifiers( - experiment=experiment, - output_type="data_QC", - analysis_number=0, - project_directory=project_directory, - polarity=polarity, - google_folder=google_folder, - exclude_files=exclude_files, - include_groups=include_groups, - exclude_groups=exclude_groups, - groups_controlled_vocab=groups_controlled_vocab, - rt_predict_number=rt_predict_number, - ) - - -def write_chromatograms( - metatlas_dataset: SimpleMetatlasData, output_dir: str, overwrite: bool = False, max_cpus: int = 1 -) -> None: - """ - inputs: - metatlas_dataset: a metatlas_dataset datastructure - output_dir: directory to save plots within - overwrite: if False raise error if file already exists - max_cpus: number of cpus to use - """ - # overwrite checks done within dp.make_chromatograms - logger.info("Exporting chromotograms to %s", output_dir) - params = { - "input_dataset": metatlas_dataset, - "share_y": True, - "output_loc": output_dir, - "overwrite": overwrite, - "max_cpus": max_cpus, - "include_lcmsruns": ["QC"], - "suffix": "_sharedY", - } - dp.make_chromatograms(**params) - params["share_y"] = False - params["suffix"] = "_independentY" - dp.make_chromatograms(**params) diff --git a/metatlas/tools/util.py b/metatlas/tools/util.py index 960f5584..eabb2972 100644 --- a/metatlas/tools/util.py +++ b/metatlas/tools/util.py @@ -1,10 +1,20 @@ """ stand alone utility functions """ +from pathlib import Path +from typing import Optional, TypeVar -def or_default(none_or_value, default): +Generic = TypeVar("Generic") + + +def or_default(none_or_value: Optional[Generic], default: Generic) -> Generic: """ inputs: none_or_value: variable to test default: value to return if none_or_value is None """ return none_or_value if none_or_value is not None else default + + +def repo_path() -> Path: + """returns Path object pointing to root directory of the git repo""" + return Path(__file__).resolve().parent.parent.parent diff --git a/notebooks/reference/RT_Prediction.ipynb b/notebooks/reference/RT_Alignment.ipynb similarity index 62% rename from notebooks/reference/RT_Prediction.ipynb rename to notebooks/reference/RT_Alignment.ipynb index 20261167..bec9f064 100644 --- a/notebooks/reference/RT_Prediction.ipynb +++ b/notebooks/reference/RT_Alignment.ipynb @@ -6,8 +6,7 @@ "tags": [] }, "source": [ - "# RT Prediction\n", - "### Targeted Analysis\n", + "# RT Alignment\n", "See the [Targeted_Analysis.md](https://github.com/biorack/metatlas/blob/main/docs/Targeted_Analysis.md) file on GitHub for documentation on how to use this notebook.\n", "\n", "#### Parameters\n", @@ -26,86 +25,73 @@ "source": [ "# pylint: disable=invalid-name,missing-module-docstring\n", "\n", - "# one of 'positive' or 'negative'\n", - "# uses this polarity for RT prediction, but will generate subsequent notebooks for both polarities\n", - "polarity = \"positive\"\n", - "\n", - "# an integer, increment if you need to rerun this notebook for the same experiment\n", - "rt_predict_number = 0\n", + "# The name of a workflow defined in the configuration file\n", + "workflow_name = None\n", "\n", "# experiment ID that must match the parent folder containing the LCMS output files\n", "# An example experiment ID is '20201116_JGI-AK_LH_506489_SoilWarm_final_QE-HF_HILICZ_USHXG01530'\n", - "experiment = \"REPLACE ME\"\n", + "experiment = None\n", "\n", - "# group will only be used in RT prediction if their name has a substring match to this list of strings\n", - "include_groups = None\n", + "# an integer, increment if you need to rerun this notebook for the same experiment\n", + "rt_alignment_number = None\n", "\n", - "# Exclude files with names containing any of the substrings in this list. Eg., ['peas', 'beans']\n", - "exclude_files = None\n", + "# source atlas name\n", + "source_atlas = None\n", + "\n", + "# group will only be used in RT alignment model if their name has a substring match to this list of strings\n", + "include_groups = None\n", "\n", "# Exclude groups with names containing any of the substrings in this list.\n", - "# 'POS' or 'NEG' will be auto-appended later, so you shouldn't use them here.\n", + "# Generally you will want to include polarities you are not using\n", + "# such as ['NEG', 'FPS'] for a positive polarity analysis.\n", "exclude_groups = None\n", "\n", "# list of substrings that will group together when creating groups\n", "# this provides additional grouping beyond the default grouping on field #12\n", - "groups_controlled_vocab = [\"QC\", \"InjBl\", \"ISTD\"]\n", - "\n", - "# thresholds for filtering out compounds with weak MS1 signals\n", - "# set to None to disable a filter\n", - "num_points = 5\n", - "peak_height = 4e5\n", + "groups_controlled_vocab = None\n", "\n", - "# threshold for filtering out compounds with poor MS2 spectra similaritiy\n", - "# Should be a value in range 0 to 1. Set to None to disable this filter.\n", - "msms_score = None\n", + "# Exclude files with names containing any of the substrings in this list. Eg., ['peas', 'beans']\n", + "exclude_files = None\n", "\n", "# Override the rt_min and rt_max values in the atlas\n", "# both rt_min_delta and rt_max_delta are *added* to rt_peak, so rt_min_delta < rt_max_delta.\n", "# Normally you will have rt_min_delta < 0 and rt_max_delta > 0\n", "# but you can have both of them be positive or both negative for extreme cases.\n", "# Set to None to use the rt_min and rt_max values saved in the template atlas\n", - "# Only impacts the atlas used for RT prediction, not subsequent atlases.\n", + "# Only impacts the atlas used for RT alignment, not subsequent atlases.\n", "rt_min_delta = None\n", "rt_max_delta = None\n", "\n", - "# if True, use a 2nd order polynomial model for RT prediction.\n", - "# if False, use a liner model\n", - "use_poly_model = True\n", - "\n", - "# if True, don't generate any atlases or notebooks\n", - "# if False, an atlas with adjusted RTs and follow up notebooks will be generated\n", - "# this parameter is deprecated, transition to using stop_before instead\n", - "model_only = False\n", - "\n", - "# One of 'qc_plots', 'atlases', 'notebooks', 'msms_hits', None\n", - "# Terminates processing of RT_Prediction.ipynb early\n", - "# model_only overrides this setting\n", - "# model_only = True is equivalent to stop_before = 'qc_plots'\n", - "# None generates all outputs\n", - "# normal processing order is:\n", - "# 1. generate model\n", - "# makes Actual_vs_Predicted_RTs.pdf, RT_Predicted_Model_Comparison.csv, rt_model.txt\n", - "# 2. create QC_plots\n", - "# makes all other plots in data_QC such as EICs, mirror plots, Compound_Atlas_*.pdf\n", - "# 3. create RT adjusted atlases\n", - "# 4. create notebooks for subsequent analysis\n", - "# 5. generate MSMS hits for subsequent notebooks and filter atlases\n", - "stop_before = None\n", + "# List of InChi Keys to be ignored when creating the RT alignment model.\n", + "inchi_keys_not_in_model = None\n", "\n", "# The QC run or name of the summary statistic generated from all QC runs\n", "# that will be used as the data source for the dependent variable for RT model generation.\n", "# Can be the name of a summary statistic generated from all QC runs:\n", "# \"median\", \"mean\", \"min\", max\"\n", "# Or a specific QC run, by supplying the name of an h5 file (without the path)\n", - "dependent_data_source = \"median\"\n", + "dependent_data_source = None\n", "\n", - "# List of InChi Keys to be ignored when creating the RT prediction model.\n", - "inchi_keys_not_in_model = None\n", + "# if True, use a 2nd order polynomial model for RT alignment.\n", + "# if False, use a liner model\n", + "use_poly_model = None\n", + "\n", + "# One of \"atlases\", \"notebook_generation\", \"notebook_execution\", None\n", + "# Terminates processing of RT_Alignment.ipynb early\n", + "# None generates all outputs\n", + "# normal processing order is:\n", + "# 1. generate RT alignment model\n", + "# makes Actual_vs_Aligned_RTs.pdf, RT_Alignment_Model_Comparison.csv, rt_alignment_model.txt\n", + "# 2. create follow up analysis notebooks\n", + "# 3. executes follow up analysis notebooks \n", + "stop_before = None\n", "\n", "\n", "# The rest of this block contains project independent parameters\n", "\n", + "# Configuration file location\n", + "config_file_name = None\n", + "\n", "# to use an older version of the metatlas source code, set this to a commit id,\n", "# branch name, or tag. If None, then use the the \"main\" branch.\n", "source_code_version_id = None\n", @@ -115,19 +101,19 @@ "# You can place this anywhere on cori's filesystem, but placing it within your\n", "# global home directory is recommended so that you do not need to worry about\n", "# your data being purged. Each project will take on the order of 100 MB.\n", - "project_directory = \"/global/homes/FIRST-INITIAL-OF-USERNAME/USERNAME/metabolomics_projects\"\n", + "project_directory = None\n", "\n", "# ID from Google Drive URL for base output folder .\n", "# The default value is the ID that corresponds to 'JGI_Metabolomics_Projects'.\n", - "google_folder = \"0B-ZDcHbPi-aqZzE5V3hOZFc0dms\"\n", + "google_folder = None\n", "\n", "# maximum number of CPUs to use\n", "# when running on jupyter.nersc.gov, you are not allowed to set this above 4\n", - "max_cpus = 4\n", + "max_cpus = None\n", "\n", "# Threshold for how much status information metatlas functions print in the notebook\n", "# levels are 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'\n", - "log_level = \"INFO\"" + "log_level = None" ] }, { @@ -153,6 +139,8 @@ " pass\n", "\n", "\n", + "parameter_names = {k for k in globals().keys() if not k.startswith(\"_\")} - {\"In\", \"Out\", \"get_ipython\", \"exit\", \"quit\"}\n", + "parameters = {k: v for k, v in globals().items() if k in parameter_names}\n", "logger = logging.getLogger(\"metatlas.jupyter\")\n", "kernel_def = \"\"\"{\"argv\":[\"shifter\",\"--entrypoint\",\"--image=wjhjgi/metatlas_shifter:latest\",\"/usr/local/bin/python\",\"-m\",\n", " \"ipykernel_launcher\",\"-f\",\"{connection_file}\"],\"display_name\": \"Metatlas Targeted\",\"language\": \"python\",\n", @@ -169,31 +157,13 @@ " logger.critical('CRITICAL: Notebook kernel has been installed. Set kernel to \"Metatlas Targeted\" and re-run notebook.')\n", " raise StopExecution\n", "try:\n", - " from metatlas.tools import notebook # noqa: E402\n", + " from metatlas.tools import config, notebook # noqa: E402\n", + " from metatlas.targeted import rt_alignment # noqa: E402\n", "except ImportError as err:\n", " logger.critical('CRITICAL: Set notebook kernel to \"Metatlas Targeted\" and re-run notebook.')\n", " raise StopExecution from err\n", - "notebook.setup(log_level, source_code_version_id)\n", - "from metatlas.tools import notebook, predict_rt # noqa: E402" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ids = predict_rt.get_analysis_ids_for_rt_prediction(\n", - " experiment,\n", - " project_directory,\n", - " google_folder,\n", - " rt_predict_number,\n", - " polarity,\n", - " exclude_files,\n", - " include_groups,\n", - " exclude_groups,\n", - " groups_controlled_vocab,\n", - ")" + "configuration, workflow, analysis = config.get_config(parameters)\n", + "notebook.setup(analysis.parameters.log_level, analysis.parameters.source_code_version_id)" ] }, { @@ -202,20 +172,11 @@ "metadata": {}, "outputs": [], "source": [ - "predict_rt.generate_outputs(\n", - " ids,\n", - " max_cpus,\n", - " num_points=num_points,\n", - " peak_height=peak_height,\n", - " msms_score=msms_score,\n", - " use_poly_model=use_poly_model,\n", - " model_only=model_only,\n", - " selected_col=dependent_data_source,\n", - " stop_before=stop_before,\n", - " source_code_version_id=source_code_version_id,\n", - " rt_min_delta=rt_min_delta,\n", - " rt_max_delta=rt_max_delta,\n", - " inchi_keys_not_in_model=inchi_keys_not_in_model,\n", + "rt_alignment.run(\n", + " experiment=experiment,\n", + " rt_alignment_number=rt_alignment_number,\n", + " configuration=configuration,\n", + " workflow=workflow,\n", ")" ] } @@ -223,9 +184,9 @@ "metadata": { "anaconda-cloud": {}, "kernelspec": { - "display_name": "Metatlas Targeted", + "display_name": "papermill", "language": "python", - "name": "metatlas-targeted" + "name": "papermill" }, "language_info": { "codemirror_mode": { @@ -237,7 +198,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.12" + "version": "3.8.13" } }, "nbformat": 4, diff --git a/notebooks/reference/Targeted.ipynb b/notebooks/reference/Targeted.ipynb index 06884972..f57c6a7d 100644 --- a/notebooks/reference/Targeted.ipynb +++ b/notebooks/reference/Targeted.ipynb @@ -23,52 +23,60 @@ "source": [ "# pylint: disable=invalid-name,missing-module-docstring\n", "\n", + "# The name of a workflow defined in the configuration file\n", + "workflow_name = None\n", + "\n", + "# The name of an analysis within the workflow\n", + "analysis_name = None\n", + "\n", "# source atlas name\n", "source_atlas = None\n", "\n", - "# this atlas will be copied to an atlas named projectId_experimentName_sampleSet_polarity_analysisId\n", - "# where projectId is JGI Proposal ID Number\n", - "# experiment name is short text description from field 4 (0-indexed) of LCMS filename\n", - "# sampleSet is commonly Pilot, Final - from field 5 (0-indexed) of LCMS filename\n", - "# polarity is 'POS' or 'NEG'\n", - "# analysisId is usernameX where X is the analysis number\n", + "# if copy_atlas is True, then generate an atlas specifically for this analysis_number\n", + "# should only be set to False if an analysis will not be modifying the atlas or RT ranges\n", + "copy_atlas = None\n", "\n", "# one of 'positive' or 'negative'\n", - "polarity = \"positive\"\n", - "\n", - "# one of 'ISTDsEtc', 'FinalEMA-HILIC', 'FinalEMA-C18'\n", - "output_type = \"FinalEMA-HILIC\"\n", + "polarity = None\n", "\n", "# an integer, increment if you need to redo your analysis\n", "# will be appended to your username to create analysis_id\n", - "analysis_number = 0\n", + "analysis_number = None\n", "\n", "# experiment ID that must match the parent folder containing the LCMS output files\n", "# An example experiment ID is '20201116_JGI-AK_LH_506489_SoilWarm_final_QE-HF_HILICZ_USHXG01530'\n", - "experiment = \"REPLACE ME\"\n", + "experiment = None\n", "\n", - "# Exclude files with names containing any of the substrings in this list. Eg., ['peas', 'beans']\n", - "exclude_files = []\n", + "# group will only be used if their name has a substring match to this list of strings\n", + "include_groups = None\n", "\n", "# Exclude groups with names containing any of the substrings in this list.\n", - "# 'POS' or 'NEG' will be auto-appended later, so you shouldn't use them here.\n", - "exclude_groups = [\"QC\", \"InjBl\"]\n", + "# Generally you will want to include polarities you are not using\n", + "# such as ['NEG', 'FPS'] for a positive polarity analysis.\n", + "exclude_groups = None\n", + "\n", + "# list of substrings that will group together when creating groups\n", + "# this provides additional grouping beyond the default grouping on field #12\n", + "groups_controlled_vocab = None\n", + "\n", + "# Exclude files with names containing any of the substrings in this list. Eg., ['peas', 'beans']\n", + "exclude_files = None\n", + "\n", + "# Create outputs used to QC the run\n", + "generate_qc_outputs = None\n", "\n", "# thresholds for filtering out compounds with weak MS1 signals\n", "# set to None to disable a filter\n", - "num_points = 5\n", - "peak_height = 4e5\n", + "num_points = None\n", + "peak_height = None\n", "\n", "# threshold for filtering out compounds with poor MS2 spectra similaritiy\n", "# Should be a value in range 0 to 1. Set to None to disable this filter.\n", "msms_score = None\n", "\n", - "# include MSMS fragment ions in the output documents?\n", - "export_msms_fragment_ions = False\n", - "\n", - "# list of substrings that will group together when creating groups\n", - "# this provides additional grouping beyond the default grouping on field #12\n", - "groups_controlled_vocab = [\"QC\", \"InjBl\", \"ISTD\"]\n", + "# if True, the post_annotation() function will remove atlas rows marked\n", + "# 'Remove' before generating output files\n", + "filter_removed = None\n", "\n", "# list of tuples contain string with color name and substring pattern.\n", "# Lines in the EIC plot will be colored by the first substring pattern\n", @@ -77,7 +85,21 @@ "# (first is front, last is back). Named colors available in matplotlib\n", "# are here: https://matplotlib.org/3.1.0/gallery/color/named_colors.html\n", "# or use hexadecimal values '#000000'. Lines default to black.\n", - "line_colors = [(\"red\", \"ExCtrl\"), (\"green\", \"TxCtrl\"), (\"blue\", \"InjBl\")]\n", + "line_colors = None\n", + "\n", + "# Set to False to disable check that all compounds have either been\n", + "# removed or rated within the annotation GUI before generating outputs.\n", + "require_all_evaluated = None\n", + "\n", + "# If True, then create the main set of outputs\n", + "generate_analysis_outputs = None\n", + "\n", + "# Groups to be excluded when generating the post annotation outputs:\n", + "exclude_groups_for_analysis_outputs = None\n", + "\n", + "# include MSMS fragment ions in the output documents?\n", + "# has no effect if generate_post_annotation_outputs is False\n", + "export_msms_fragment_ions = None\n", "\n", "# Setting this to True will remove the cache of MSMS hits\n", "# if you don't see MSMS data for any of your compounds in RT adjuster GUI,\n", @@ -85,14 +107,17 @@ "# make your notebook take significantly longer to run.\n", "# The cache is per experiment, so clearing the cache will impact other\n", "# notebooks for this same experiment.\n", - "clear_cache = False\n", + "clear_cache = None\n", "\n", - "# This value will always be automatically passed in from the RT_Prediction\n", + "# This value will always be automatically passed in from the RT_Alignment\n", "# notebook and you should not manually set this parameter.\n", - "rt_predict_number = 0\n", + "rt_alignment_number = None\n", "\n", "# The rest of this block contains project independent parameters\n", "\n", + "# Configuration file location\n", + "config_file_name = None\n", + "\n", "# to use an older version of the metatlas source code, set this to a commit id,\n", "# branch name, or tag. If None, then use the the \"main\" branch.\n", "source_code_version_id = None\n", @@ -102,19 +127,19 @@ "# You can place this anywhere on cori's filesystem, but placing it within your\n", "# global home directory is recommended so that you do not need to worry about\n", "# your data being purged. Each project will take on the order of 100 MB.\n", - "project_directory = \"/global/homes/FIRST-INITIAL-OF-USERNAME/USERNAME/metabolomics_projects\"\n", + "project_directory = None\n", "\n", "# ID from Google Drive URL for base output folder .\n", "# The default value is the ID that corresponds to 'JGI_Metabolomics_Projects'.\n", - "google_folder = \"0B-ZDcHbPi-aqZzE5V3hOZFc0dms\"\n", + "google_folder = None\n", "\n", "# maximum number of CPUs to use\n", "# when running on jupyter.nersc.gov, you are not allowed to set this above 4\n", - "max_cpus = 4\n", + "max_cpus = None\n", "\n", "# Threshold for how much status information metatlas functions print in the notebook\n", "# levels are 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'\n", - "log_level = \"INFO\"" + "log_level = None" ] }, { @@ -142,6 +167,8 @@ " pass\n", "\n", "\n", + "parameter_names = {k for k in globals().keys() if not k.startswith(\"_\")} - {\"In\", \"Out\", \"get_ipython\", \"exit\", \"quit\"}\n", + "parameters = {k: v for k, v in globals().items() if k in parameter_names}\n", "logger = logging.getLogger(\"metatlas.jupyter\")\n", "kernel_def = \"\"\"{\"argv\":[\"shifter\",\"--entrypoint\",\"--image=wjhjgi/metatlas_shifter:latest\",\"/usr/local/bin/python\",\"-m\",\n", " \"ipykernel_launcher\",\"-f\",\"{connection_file}\"],\"display_name\": \"Metatlas Targeted\",\"language\": \"python\",\n", @@ -158,12 +185,13 @@ " logger.critical('CRITICAL: Notebook kernel has been installed. Set kernel to \"Metatlas Targeted\" and re-run notebook.')\n", " raise StopExecution\n", "try:\n", - " from metatlas.tools import notebook # noqa: E402\n", + " from metatlas.tools import config, notebook # noqa: E402\n", "except ImportError as err:\n", " logger.critical('CRITICAL: Set notebook kernel to \"Metatlas Targeted\" and re-run notebook.')\n", " raise StopExecution from err\n", - "notebook.setup(log_level, source_code_version_id)\n", - "from metatlas.datastructures import metatlas_dataset as mads # noqa: E402" + "configuration, workflow, analysis = config.get_config(parameters)\n", + "notebook.setup(analysis.parameters.log_level, analysis.parameters.source_code_version_id)\n", + "from metatlas.targeted.process import pre_annotation, annotation_gui, post_annotation # noqa: E402" ] }, { @@ -172,22 +200,15 @@ "metadata": {}, "outputs": [], "source": [ - "metatlas_dataset = mads.pre_annotation(\n", - " source_atlas,\n", - " experiment,\n", - " output_type,\n", - " polarity,\n", - " analysis_number,\n", - " project_directory,\n", - " google_folder,\n", - " groups_controlled_vocab,\n", - " exclude_files,\n", - " num_points,\n", - " peak_height,\n", - " max_cpus,\n", - " msms_score,\n", + "metatlas_dataset = pre_annotation(\n", + " experiment=experiment,\n", + " rt_alignment_number=rt_alignment_number,\n", + " analysis_number=analysis_number,\n", + " source_atlas=source_atlas,\n", + " configuration=configuration,\n", + " workflow=workflow,\n", + " analysis=analysis,\n", " clear_cache=clear_cache,\n", - " rt_predict_number=rt_predict_number,\n", ")" ] }, @@ -205,7 +226,7 @@ "metadata": {}, "outputs": [], "source": [ - "agui = metatlas_dataset.annotation_gui(compound_idx=0, width=15, height=3, colors=line_colors)" + "agui = annotation_gui(data=metatlas_dataset, compound_idx=0, width=15, height=3, colors=analysis.parameters.line_colors)" ] }, { @@ -214,16 +235,16 @@ "metadata": {}, "outputs": [], "source": [ - "mads.post_annotation(metatlas_dataset)" + "post_annotation(data=metatlas_dataset, configuration=configuration, workflow=workflow, analysis=analysis)" ] } ], "metadata": { "anaconda-cloud": {}, "kernelspec": { - "display_name": "Metatlas Targeted", + "display_name": "papermill", "language": "python", - "name": "metatlas-targeted" + "name": "papermill" }, "language_info": { "codemirror_mode": { @@ -235,7 +256,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.12" + "version": "3.8.13" } }, "nbformat": 4, diff --git a/noxfile.py b/noxfile.py index bf775c76..82f78727 100644 --- a/noxfile.py +++ b/noxfile.py @@ -26,6 +26,7 @@ # has not yet been updated to pass all checks. more_checks = [ "metatlas/interfaces/compounds/populate.py", + "metatlas/io/gdrive.py", "metatlas/io/file_converter.py", "metatlas/io/rclone.py", "metatlas/io/targeted_output.py", @@ -44,13 +45,15 @@ "metatlas/plots/utils.py", "metatlas/scripts/copy_ms_to_new_names.py", "metatlas/scripts/yaml_validation.py", + "metatlas/targeted/rt_alignment.py", + "metatlas/targeted/process.py", "metatlas/tools/add_msms_ref.py", "metatlas/tools/cheminfo.py", + "metatlas/tools/config.py", "metatlas/tools/environment.py", "metatlas/tools/logging.py", "metatlas/tools/notebook.py", "metatlas/tools/parallel.py", - "metatlas/tools/predict_rt.py", "metatlas/tools/util.py", "metatlas/tools/validate_filenames.py", "noxfile.py", @@ -61,7 +64,7 @@ # has not yet been updated to pass all checks. notebooks = [ "notebooks/reference/Targeted.ipynb", - "notebooks/reference/RT_Prediction.ipynb", + "notebooks/reference/RT_Alignment.ipynb", "notebooks/reference/Add_MSMS_Reference.ipynb", ] diff --git a/papermill/launch_rt_prediction.sh b/papermill/launch_rt_prediction.sh index 96ff6893..4ad7f235 100755 --- a/papermill/launch_rt_prediction.sh +++ b/papermill/launch_rt_prediction.sh @@ -17,13 +17,14 @@ die() { usage() { >&2 echo "Usage: - $(basename "$0") experiment_name rt_predict_number project_directory [-p notebook_parameter=value] [-y yaml_string] + $(basename "$0") workflow_name experiment_name [rt_predict_number] [project_directory] [-p notebook_parameter=value] [-y yaml_string] where: + workflow_name: name associated with a workflow definition in the configuration file experiment_name: experiment identifier rt_predict_number: integer, use 0 the first time generating an RT correction for an experiment - and increment if re-generating an RT correction - project_directory: output directory will be created within this directory + and increment if re-generating an RT correction (default: 0) + project_directory: output directory will be created within this directory (default: $HOME/metabolomics_data) -p: optional notebook parameters, can use multiple times -y: optional notebook parameters in YAML or JSON string @@ -169,6 +170,14 @@ check_analysis_dir_does_not_exist() { fi } +check_project_dir_does_exist() { + if ! [ -d "$1" ]; then + >&2 echo "ERROR: project_directory '${1}' does not exist." + >&2 echo " Please run 'mkdir \"${1}\"' first." + die + fi +} + check_exp_id_has_atleast_9_fields() { # inputs: the 9th field (1-indexed) of the experiment_name split on '_' if [[ $1 == "" ]]; then @@ -190,6 +199,16 @@ check_not_in_commom_software_filesystem() { fi } +check_rt_predict_number_is_non_neg_int() { + re='^[0-9]+$' + if ! [[ $1 =~ $re ]] ; then + >&2 echo "" + >&2 echo "ERROR: rt_predict_number must be a non-negative integer" + >&2 echo "" + die + fi +} + YAML_BASE64="$(echo "{}" | base64 --wrap=0)" declare -a positional_parameters=() declare -a extra_parameters=() @@ -207,8 +226,8 @@ do fi done -if [ ${#positional_parameters[@]} -ne 3 ]; then - >&2 echo "ERROR: one of experiment_name, rt_predict_number, or project_directory was not supplied." +if [ ${#positional_parameters[@]} -ne 2 ]; then + >&2 echo "ERROR: one of workflow_name or experiment_name was not supplied." >&2 echo "" usage fi @@ -217,9 +236,10 @@ if [ ${#extra_parameters[@]} -ne 0 ]; then validate_extra_parameters extra_parameters # pass extra_parameters by name fi -exp="${positional_parameters[0]}" -rt_predict_num="${positional_parameters[1]}" -project_dir="${positional_parameters[2]}" +workflow_name="${positional_parameters[0]}" +exp="${positional_parameters[1]}" +rt_predict_num="${positional_parameters[2]:-0}" +project_dir="${positional_parameters[3]:-$HOME/metabolomics_data}" script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && realpath .)" exp_dir="${project_dir}/$exp" @@ -231,6 +251,8 @@ exp_check_len="${TOKENS[8]:-}" check_exp_id_has_atleast_9_fields "$exp_check_len" check_analysis_dir_does_not_exist "$analysis_dir" +check_project_dir_does_exist "$project_dir" +check_rt_predict_number_is_non_neg_int "$rt_predict_num" check_yaml_is_valid "$(echo "${YAML_BASE64:-}" | base64 --decode)" check_gdrive_authorization check_not_in_commom_software_filesystem @@ -246,9 +268,11 @@ IN_FILE="/src/notebooks/reference/RT_Prediction.ipynb" OUT_FILE="${analysis_dir}/${proposal}_RT_Prediction_papermill.ipynb" PARAMETERS+=" -p experiment $exp \ + -p workflow_name '${workflow_name}' \ -p project_directory $project_dir \ -p max_cpus $threads_to_use \ - -p rt_predict_number $rt_predict_num" + -p rt_predict_number $rt_predict_num \ + -p config_file_name /global/cfs/cdirs/m2650/targeted_analysis/metatlas_config.yaml" if [ ${#extra_parameters[@]} -ne 0 ]; then for i in "${extra_parameters[@]}" do diff --git a/papermill/slurm_template.sh b/papermill/slurm_template.sh index ff537697..689c0d9a 100755 --- a/papermill/slurm_template.sh +++ b/papermill/slurm_template.sh @@ -6,7 +6,9 @@ set -euo pipefail -shifter_flags="--module=none --clearenv" +# PAPERMILL_EXECUTION is set to True so we can determine if notebooks +# are being run in papermill or interactively +shifter_flags="--module=none --clearenv --env=PAPERMILL_EXECUTION=True" log_dir="/global/cfs/projectdirs/m2650/jupyter_logs/slurm" diff --git a/pyproject.toml b/pyproject.toml index bda37116..2e988207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ module = [ "credentials.*", "dataset.*", "dill.*", + "flask.*", "ftputil.*", "gspread.*", "humanize.*", @@ -31,6 +32,7 @@ module = [ "numpy.testing.decorators", "oauth2client.*", "pandas.*", + "papermill.*", "pathlib2.*", "pexpect.*", "PIL.*", diff --git a/test_config.yaml b/test_config.yaml new file mode 100644 index 00000000..0344663e --- /dev/null +++ b/test_config.yaml @@ -0,0 +1,91 @@ +--- +chromatography_types: + - name: HILIC + aliases: + - HILICZ + - Ag683775 + - name: C18 + aliases: [] +workflows: + - name: Test-QC + rt_alignment: + atlas: + name: HILICz150_ANT20190824_TPL_QCv3_Unlab_POS + username: vrsingan + parameters: + polarity: positive + groups_controlled_vocab: ["QC", "InjBl", "ISTD"] + include_groups: ["QC"] + exclude_groups: ["NEG"] + use_poly_model: True + google_folder: 0B-ZDcHbPi-aqZzE5V3hOZFc0dms + analyses: + - name: QC-POS + atlas: + name: HILICz150_ANT20190824_TPL_QCv3_Unlab_POS + username: vrsingan + parameters: + polarity: positive + groups_controlled_vocab: ["QC", "InjBl", "ISTD"] + include_groups: ["QC"] + exclude_groups: ["NEG"] + generate_qc_outputs: True + - name: Test-HILIC + rt_alignment: + atlas: + name: HILICz150_ANT20190824_TPL_QCv3_Unlab_POS + username: vrsingan + parameters: + groups_controlled_vocab: ["QC", "InjBl", "ISTD"] + include_groups: ["QC"] + exclude_groups: ["NEG"] + use_poly_model: True + google_folder: 0B-ZDcHbPi-aqZzE5V3hOZFc0dms + analyses: + - name: EMA-POS + atlas: + name: HILICz150_ANT20190824_TPL_EMA_Unlab_POS + username: vrsingan + do_alignment: True + parameters: + copy_atlas: True + polarity: positive + exclude_groups: ["QC", "NEG", "FPS"] + exclude_groups_for_analysis_outputs: ["QC", "NEG", "FPS"] + exclude_lcmsruns_in_output_chromatograms: ["InjBl", "QC", "Blank", "blank"] + groups_controlled_vocab: ["QC", "InjBl", "ISTD"] + filter_removed: True + num_points: 5 + peak_height: 4e5 + generate_analysis_outputs: True + msms_refs: /global/cfs/cdirs/metatlas/projects/spectral_libraries/msms_refs_v3.tab + - name: Test-C18 + rt_alignment: + atlas: + name: C18_20220215_TPL_IS_Unlab_POS + username: wjholtz + parameters: + groups_controlled_vocab: ["QC", "InjBl", "ISTD"] + include_groups: ["QC"] + exclude_groups: ["NEG"] + use_poly_model: True + google_folder: 0B-ZDcHbPi-aqZzE5V3hOZFc0dms + analyses: + - name: EMA-NEG + atlas: + name: C18_20220531_TPL_EMA_Unlab_NEG + username: wjholtz + do_alignment: True + parameters: + copy_atlas: True + polarity: negative + groups_controlled_vocab: ["QC", "InjBl", "ISTD"] + exclude_groups: ["QC", "POS", "FPS"] + exclude_groups_for_analysis_outputs: ["QC", "POS", "FPS"] + exclude_lcmsruns_in_output_chromatograms: ["InjBl", "QC", "Blank", "blank"] + num_points: 3 + peak_height: 1e6 + msms_score: 0.6 + filter_removed: True + generate_analysis_outputs: True + msms_refs: /global/cfs/cdirs/metatlas/projects/spectral_libraries/msms_refs_v3.tab diff --git a/tests/system/test_add_msms_ref.py b/tests/system/test_add_msms_ref.py index 78b1c742..b78db0e0 100644 --- a/tests/system/test_add_msms_ref.py +++ b/tests/system/test_add_msms_ref.py @@ -4,7 +4,7 @@ def test_add_msms_ref_by_line01(tmp_path): - image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci01:v1.4.21" + image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci01:v1.4.22" expected = {} expected[ str(tmp_path / "updated_refs.tab") @@ -32,6 +32,6 @@ def test_add_msms_ref_by_line01(tmp_path): /out/Remove.ipynb \ /out/Remove-done.ipynb """ - utils.exec_docker(image, command, tmp_path) + utils.exec_docker(image, command, tmp_path, utils.PAPERMILL_ENV) assert utils.num_files_in(tmp_path) == 4 utils.assert_files_match(expected) diff --git a/tests/system/test_c18.py b/tests/system/test_c18.py index d2491b1e..1005916f 100644 --- a/tests/system/test_c18.py +++ b/tests/system/test_c18.py @@ -4,11 +4,11 @@ def test_c18_by_line01_with_remove(tmp_path): - image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci03:v0.0.7" + image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci03:v0.0.8" experiment = "20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680" expected = {} expected[ - str(tmp_path / experiment / "root_0_0/FinalEMA-C18/NEG/NEG_data_sheets/NEG_peak_height.tab") + str(tmp_path / experiment / "root_0_0/Targeted/Test-C18/EMA-NEG/NEG_data_sheets/NEG_peak_height.tab") ] = """group 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_ExCtrl 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_Neg-D30 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_Neg-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_Neg-D89 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S16-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S16-D89 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S32-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S40-D30 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S40-D89 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S53-D30 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S53-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S53-D89 file 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_75_ExCtrl_C_Rg80to1200-CE102040-soil-S1_Run209.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_57_Neg-D30_C_Rg80to1200-CE102040-soil-S1_Run224.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_60_Neg-D45_C_Rg80to1200-CE102040-soil-S1_Run230.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_63_Neg-D89_C_Rg80to1200-CE102040-soil-S1_Run215.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_12_S16-D45_C_Rg80to1200-CE102040-soil-S1_Run203.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_21_S16-D89_C_Rg80to1200-CE102040-soil-S1_Run221.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_18_S32-D45_C_Rg80to1200-CE102040-soil-S1_Run236.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_33_S40-D30_C_Rg80to1200-CE102040-soil-S1_Run233.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_51_S40-D89_C_Rg80to1200-CE102040-soil-S1_Run227.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_36_S53-D30_C_Rg80to1200-CE102040-soil-S1_Run218.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_45_S53-D45_C_Rg80to1200-CE102040-soil-S1_Run212.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_54_S53-D89_C_Rg80to1200-CE102040-soil-S1_Run206.h5 short groupname NEG_ExCtrl NEG_Neg-D30 NEG_Neg-D45 NEG_Neg-D89 NEG_S16-D45 NEG_S16-D89 NEG_S32-D45 NEG_S40-D30 NEG_S40-D89 NEG_S53-D30 NEG_S53-D45 NEG_S53-D89 @@ -19,7 +19,7 @@ def test_c18_by_line01_with_remove(tmp_path): 0001_vulpinic_acid_negative_M-H321p0768_6p26 2.963439258e+04 9.831337500e+05 1.175015332e+04 1.064087402e+04 1.329986625e+06 4.145107812e+05 1.812218750e+05""" expected[ - str(tmp_path / experiment / "root_0_0/FinalEMA-C18/NEG/NEG_data_sheets/NEG_rt_peak.tab") + str(tmp_path / experiment / "root_0_0/Targeted/Test-C18/EMA-NEG/NEG_data_sheets/NEG_rt_peak.tab") ] = """group 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_ExCtrl 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_Neg-D30 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_Neg-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_Neg-D89 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S16-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S16-D89 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S32-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S40-D30 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S40-D89 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S53-D30 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S53-D45 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_root_0_0_S53-D89 file 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_75_ExCtrl_C_Rg80to1200-CE102040-soil-S1_Run209.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_57_Neg-D30_C_Rg80to1200-CE102040-soil-S1_Run224.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_60_Neg-D45_C_Rg80to1200-CE102040-soil-S1_Run230.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_63_Neg-D89_C_Rg80to1200-CE102040-soil-S1_Run215.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_12_S16-D45_C_Rg80to1200-CE102040-soil-S1_Run203.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_21_S16-D89_C_Rg80to1200-CE102040-soil-S1_Run221.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_18_S32-D45_C_Rg80to1200-CE102040-soil-S1_Run236.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_33_S40-D30_C_Rg80to1200-CE102040-soil-S1_Run233.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_51_S40-D89_C_Rg80to1200-CE102040-soil-S1_Run227.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_36_S53-D30_C_Rg80to1200-CE102040-soil-S1_Run218.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_45_S53-D45_C_Rg80to1200-CE102040-soil-S1_Run212.h5 20210915_JGI-AK_MK_506588_SoilWaterRep_final_QE-HF_C18_USDAY63680_NEG_MSMS_54_S53-D89_C_Rg80to1200-CE102040-soil-S1_Run206.h5 short groupname NEG_ExCtrl NEG_Neg-D30 NEG_Neg-D45 NEG_Neg-D89 NEG_S16-D45 NEG_S16-D89 NEG_S32-D45 NEG_S40-D30 NEG_S40-D89 NEG_S53-D30 NEG_S53-D45 NEG_S53-D89 @@ -42,18 +42,20 @@ def test_c18_by_line01_with_remove(tmp_path): "agui.data.set_rt(1, \\"rt_max\\", 6.2470)\\n" \ ]' /src/notebooks/reference/Targeted.ipynb > /out/Remove.ipynb && \ papermill -k papermill \ + -p config_file_name /src/test_config.yaml \ + -p workflow_name Test-C18 \ + -p analysis_name EMA-NEG \ -p source_atlas C18_20220215_TPL_EMA_Unlab_NEG \ + -p analysis_number 0 \ + -p rt_alignment_number 0 \ -p experiment {experiment} \ -p polarity negative \ - -p output_type FinalEMA-C18 \ -p project_directory /out \ - -p num_points None \ - -p peak_height None \ -p msms_score 0.5 \ -p max_cpus 2 \ /out/Remove.ipynb \ /out/Remove-done.ipynb """ - utils.exec_docker(image, command, tmp_path) + utils.exec_docker(image, command, tmp_path, {}) assert utils.num_files_in(tmp_path) == 46 utils.assert_files_match(expected) diff --git a/tests/system/test_rt_predict.py b/tests/system/test_rt_alignment.py similarity index 78% rename from tests/system/test_rt_predict.py rename to tests/system/test_rt_alignment.py index 596d118d..a4d05251 100644 --- a/tests/system/test_rt_predict.py +++ b/tests/system/test_rt_alignment.py @@ -5,12 +5,12 @@ from . import utils -def test_rt_predict_by_line01(tmp_path): - image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci02:v1.4.23" +def test_rt_alignment_by_line01(tmp_path): + image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci02:v1.4.24" experiment = "20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583" expected = {} expected[ - str(tmp_path / experiment / "root_0_0/data_QC/rt_model.txt") + str(tmp_path / experiment / "root_0_0/Targeted/Test-QC/RT_Alignment/rt_alignment_model.txt") ] = """RANSACRegressor(random_state=42) Linear model with intercept=0.430 and slope=0.95574 groups = 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_FPS_MS1_root_0_0_QC, 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_QC @@ -22,7 +22,11 @@ def test_rt_predict_by_line01(tmp_path): atlas = HILICz150_ANT20190824_TPL_QCv3_Unlab_POS """ expected_df = {} - expected_df[str(tmp_path / experiment / "root_0_0/data_QC/RT_Predicted_Model_Comparison.csv")] = { + expected_df[ + str( + tmp_path / experiment / "root_0_0/Targeted/Test-QC/RT_Alignment/RT_Alignment_Model_Comparison.csv" + ) + ] = { "Unnamed: 0": { 0: "0000_uracil_unlabeled_positive_M+H113p0346_1p39", 1: "0001_cytosine_unlabeled_positive_M+H112p0505_4p83", @@ -30,13 +34,13 @@ def test_rt_predict_by_line01(tmp_path): }, "RT Measured": {0: 1.884217, 1: 4.878586, 2: 13.32883}, "RT Reference": {0: 1.3937, 1: 4.833664, 2: 13.44515}, - "RT Linear Pred": {0: 1.761967, 1: 5.049671, 2: 13.28}, - "RT Polynomial Pred": {0: 1.884217, 1: 4.878586, 2: 13.32883}, + "Relative RT Linear": {0: 1.761967, 1: 5.049671, 2: 13.28}, + "Relative RT Polynomial": {0: 1.884217, 1: 4.878586, 2: 13.32883}, "RT Diff Linear": {0: 0.1222508, 1: -0.1710854, 2: 0.04883459}, "RT Diff Polynomial": {0: -1.865175e-14, 1: 1.776357e-15, 2: 1.776357e-14}, } - expected_df[str(tmp_path / experiment / "root_0_0/data_QC/POS/POS_QC_Measured_RTs.csv")] = { + expected_df[str(tmp_path / experiment / "root_0_0/Targeted/Test-QC/QC-POS/POS_QC_Measured_RTs.csv")] = { "Unnamed: 0": { 0: "0000_uracil_unlabeled_positive_M+H113p0346_1p39", 1: "0001_cytosine_unlabeled_positive_M+H112p0505_4p83", @@ -73,13 +77,15 @@ def test_rt_predict_by_line01(tmp_path): command = """papermill -k papermill \ -p experiment 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583 \ - -p model_only False \ -p project_directory /out \ -p max_cpus 2 \ - /src/notebooks/reference/RT_Prediction.ipynb \ + -p config_file_name /src/test_config.yaml \ + -p workflow_name Test-QC \ + -p rt_alignment_number 0 \ + /src/notebooks/reference/RT_Alignment.ipynb \ /out/Remove-done.ipynb """ - utils.exec_docker(image, command, tmp_path) - assert utils.num_files_in(tmp_path) == 81 + utils.exec_docker(image, command, tmp_path, utils.PAPERMILL_ENV) + assert utils.num_files_in(tmp_path) == 21 utils.assert_files_match(expected) utils.assert_dfs_match(expected_df) diff --git a/tests/system/test_targeted.py b/tests/system/test_targeted.py index 1e45a6db..cfa43ad9 100644 --- a/tests/system/test_targeted.py +++ b/tests/system/test_targeted.py @@ -4,11 +4,13 @@ def test_targeted_by_line01_with_remove(tmp_path): - image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci01:v1.4.21" + image = "registry.spin.nersc.gov/metatlas_test/metatlas_ci01:v1.4.22" experiment = "20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583" expected = {} expected[ - str(tmp_path / experiment / "root_0_0/FinalEMA-HILIC/POS/POS_data_sheets/POS_peak_height.tab") + str( + tmp_path / experiment / "root_0_0/Targeted/Test-HILIC/EMA-POS/POS_data_sheets/POS_peak_height.tab" + ) ] = """group 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S1 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S2 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S3 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S4 file 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_49_Cone-S1_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run34.h5 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_57_Cone-S2_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run40.h5 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_65_Cone-S3_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run16.h5 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_73_Cone-S4_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run31.h5 short groupname POS_Cone-S1 POS_Cone-S2 POS_Cone-S3 POS_Cone-S4 @@ -19,7 +21,7 @@ def test_targeted_by_line01_with_remove(tmp_path): 0001_adenine_positive_M+H136p0618_2p52 1.594753875e+06 1.209648500e+07 5.177495600e+07 9.195548800e+07 0002_adenosine_positive_M+H268p1041_3p02 2.661186800e+07 1.197741840e+08 2.677188800e+08 4.739050240e+08""" expected[ - str(tmp_path / experiment / "root_0_0/FinalEMA-HILIC/POS/POS_data_sheets/POS_rt_peak.tab") + str(tmp_path / experiment / "root_0_0/Targeted/Test-HILIC/EMA-POS/POS_data_sheets/POS_rt_peak.tab") ] = """group 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S1 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S2 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S3 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_root_0_0_Cone-S4 file 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_49_Cone-S1_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run34.h5 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_57_Cone-S2_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run40.h5 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_65_Cone-S3_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run16.h5 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_73_Cone-S4_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run31.h5 short groupname POS_Cone-S1 POS_Cone-S2 POS_Cone-S3 POS_Cone-S4 @@ -52,9 +54,14 @@ def test_targeted_by_line01_with_remove(tmp_path): -p experiment 20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583 \ -p project_directory /out \ -p max_cpus 2 \ + -p config_file_name /src/test_config.yaml \ + -p workflow_name Test-HILIC\ + -p analysis_name EMA-POS \ + -p rt_alignment_number 0 \ + -p analysis_number 0 \ /out/Remove.ipynb \ /out/Remove-done.ipynb """ - utils.exec_docker(image, command, tmp_path) + utils.exec_docker(image, command, tmp_path, {}) assert utils.num_files_in(tmp_path) == 55 utils.assert_files_match(expected) diff --git a/tests/system/utils.py b/tests/system/utils.py index 52d500c5..899a7941 100644 --- a/tests/system/utils.py +++ b/tests/system/utils.py @@ -8,6 +8,8 @@ import pandas as pd +PAPERMILL_ENV = {"PAPERMILL_EXECUTION": "True"} + def num_files_in(path: os.PathLike) -> int: """Returns number of files in path. Does not count directories""" @@ -60,8 +62,9 @@ def assert_dfs_match(expected: Dict[os.PathLike, Dict[str, Dict[int, Any]]]) -> pd.testing.assert_frame_equal(disk_df, expected_df) -def exec_docker(image: str, command: str, out_path: os.PathLike) -> None: +def exec_docker(image: str, command: str, out_path: os.PathLike, env: Dict) -> None: """execute command in image with out_path mounted at /out""" + env_flags = [x for key, value in env.items() for x in ("--env", f"{key}={value}")] subprocess.run( [ "docker", @@ -71,6 +74,7 @@ def exec_docker(image: str, command: str, out_path: os.PathLike) -> None: f"{os.getcwd()}:/src", "-v", f"{out_path}:/out", + *env_flags, image, "/bin/bash", "-c", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 2b72aa6c..a1b3860e 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -22,7 +22,10 @@ import metatlas.datastructures.metatlas_dataset as mads import metatlas.datastructures.metatlas_objects as metob import metatlas.datastructures.object_helpers as metoh -from metatlas.tools import predict_rt + +from metatlas.targeted import rt_alignment +from metatlas.tools import config +from metatlas.tools.util import repo_path logger = logging.getLogger(__name__) @@ -37,49 +40,51 @@ def fixture_username(): @pytest.fixture(name="analysis_ids") -def fixture_analysis_ids(sqlite_with_atlas, username, lcmsrun, mocker, groups_controlled_vocab): +def fixture_analysis_ids(sqlite_with_atlas, lcmsrun, configuration, mocker): mocker.patch("metatlas.plots.dill2plots.get_metatlas_files", return_value=[lcmsrun]) project_directory = str(os.getcwd()) experiment = "20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583" - output_type = "FinalEMA-HILIC" polarity = "positive" analysis_number = 0 google_folder = "0B-ZDcHbPi-aqZzE5V3hOZFc0dms" return ids.AnalysisIdentifiers( - project_directory, - experiment, - output_type, - polarity, - analysis_number, - google_folder, - source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", + project_directory=project_directory, + experiment=experiment, + polarity=polarity, + analysis_number=analysis_number, + google_folder=google_folder, + source_atlas="HILICz150_ANT20190824_PRD_EMA_Unlab_POS", + copy_atlas=True, + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) @pytest.fixture(name="analysis_ids_with_2_cids") -def fixture_analysis_ids_with_2_cids( - sqlite_with_atlas_with_2_cids, username, lcmsrun, mocker, groups_controlled_vocab -): +def fixture_analysis_ids_with_2_cids(sqlite_with_atlas_with_2_cids, lcmsrun, configuration, mocker): mocker.patch("metatlas.plots.dill2plots.get_metatlas_files", return_value=[lcmsrun]) project_directory = str(os.getcwd()) experiment = "20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583" - output_type = "FinalEMA-HILIC" polarity = "positive" analysis_number = 0 google_folder = "0B-ZDcHbPi-aqZzE5V3hOZFc0dms" return ids.AnalysisIdentifiers( - project_directory, - experiment, - output_type, - polarity, - analysis_number, - google_folder, - source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", + project_directory=project_directory, + experiment=experiment, + polarity=polarity, + analysis_number=analysis_number, + google_folder=google_folder, + source_atlas="HILICz150_ANT20190824_PRD_EMA_Unlab_POS", + copy_atlas=True, + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) @pytest.fixture(name="sqlite") -def fixture_sqlite(username, change_test_dir, atlas): +def fixture_sqlite(username): logging.debug("creating database file in %s", os.getcwd()) assert not os.path.exists(f"{username}_workspace.db") sqlite3.connect(f"{username}_workspace.db").close() @@ -97,15 +102,15 @@ def fixture_sqlite(username, change_test_dir, atlas): @pytest.fixture(name="sqlite_with_atlas") -def fixture_sqlite_with_atlas(sqlite, atlas, username): - atlas.name = f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0" +def fixture_sqlite_with_atlas(sqlite, atlas): + atlas.name = "HILICz150_ANT20190824_PRD_EMA_Unlab_POS" logger.debug("Saving atlas %s", atlas.name) metob.store(atlas) @pytest.fixture(name="sqlite_with_atlas_with_2_cids") -def fixture_sqlite_with_atlas_with_2_cids(sqlite, atlas_with_2_cids, username): - atlas_with_2_cids.name = f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0" +def fixture_sqlite_with_atlas_with_2_cids(sqlite, atlas_with_2_cids): + atlas_with_2_cids.name = "HILICz150_ANT20190824_PRD_EMA_Unlab_POS" logger.debug("Saving atlas %s", atlas_with_2_cids.name) metob.store(atlas_with_2_cids) @@ -2274,6 +2279,33 @@ def fixture_model(): transformed_pred = np.array([4, 5, 6]).reshape(-1, 1) ransac = RANSACRegressor(random_state=42) rt_model_linear = ransac.fit(transformed_pred, transformed_actual) - return predict_rt.Model( + return rt_alignment.Model( rt_model_linear, rt_model_linear.estimator_.intercept_[0], rt_model_linear.estimator_.coef_ ) + + +@pytest.fixture(name="analysis_parameters") +def fixture_analysis_parameters(): + return { + "config_file_name": repo_path() / "test_config.yaml", + "workflow_name": "Test-HILIC", + "analysis_name": "EMA-POS", + } + + +@pytest.fixture(name="configuration") +def fixture_configuration(analysis_parameters): + configuration, _, _ = config.get_config(analysis_parameters) + return configuration + + +@pytest.fixture(name="workflow") +def fixture_workflow(analysis_parameters): + _, workflow, _ = config.get_config(analysis_parameters) + return workflow + + +@pytest.fixture(name="analysis") +def fixture_analysis(analysis_parameters): + _, _, analysis = config.get_config(analysis_parameters) + return analysis diff --git a/tests/unit/test_analysis_identifiers.py b/tests/unit/test_analysis_identifiers.py index dfadbaaf..0ca4148b 100644 --- a/tests/unit/test_analysis_identifiers.py +++ b/tests/unit/test_analysis_identifiers.py @@ -8,33 +8,22 @@ from metatlas.datastructures.analysis_identifiers import AnalysisIdentifiers -def test_analysis_identifiers01(sqlite): +def test_analysis_identifiers01(sqlite, configuration): with pytest.raises(traitlets.traitlets.TraitError, match=r"Database does not contain an atlas.*"): AnalysisIdentifiers( source_atlas="Not_A_Real_Atlas_Name", experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="FinalEMA-HILIC", polarity="positive", analysis_number=1, google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", project_directory="/foo/bar", + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) -def test_analysis_identifiers02(sqlite_with_atlas, username): - with pytest.raises(traitlets.traitlets.TraitError, match="Parameter output_type must be one of"): - AnalysisIdentifiers( - source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", - experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="output_type_not_valid", - polarity="positive", - analysis_number=1, - google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", - project_directory="/foo/bar", - ) - - -def test_analysis_identifiers03(username, sqlite_with_atlas): +def test_analysis_identifiers03(username, sqlite_with_atlas, configuration): with pytest.raises( traitlets.traitlets.TraitError, match="Parameter polarity must be one of positive, negative, fast-polarity-switching", @@ -42,15 +31,17 @@ def test_analysis_identifiers03(username, sqlite_with_atlas): AnalysisIdentifiers( source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="FinalEMA-HILIC", polarity="not a polarity value", analysis_number=1, google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", project_directory="/foo/bar", + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) -def test_analysis_identifiers04(username, sqlite_with_atlas): +def test_analysis_identifiers04(username, sqlite_with_atlas, configuration): with pytest.raises( traitlets.traitlets.TraitError, match="The 'analysis_number' trait of an AnalysisIdentifiers instance expected an int, not", @@ -58,15 +49,17 @@ def test_analysis_identifiers04(username, sqlite_with_atlas): AnalysisIdentifiers( source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="FinalEMA-HILIC", polarity="positive", analysis_number="this is a string", google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", project_directory="/foo/bar", + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) -def test_analysis_identifiers05(username, sqlite_with_atlas): +def test_analysis_identifiers05(username, sqlite_with_atlas, configuration): with pytest.raises( traitlets.traitlets.TraitError, match="The 'analysis_number' trait of an AnalysisIdentifiers instance expected an int, not", @@ -74,28 +67,32 @@ def test_analysis_identifiers05(username, sqlite_with_atlas): AnalysisIdentifiers( source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="FinalEMA-HILIC", polarity="positive", analysis_number="1", google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", project_directory="/foo/bar", + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) -def test_analysis_identifiers06(username, sqlite_with_atlas): +def test_analysis_identifiers06(username, sqlite_with_atlas, configuration): with pytest.raises(traitlets.traitlets.TraitError, match="Parameter analysis_number cannot be negative."): AnalysisIdentifiers( source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="FinalEMA-HILIC", polarity="positive", analysis_number=-9, google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", project_directory="/foo/bar", + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) -def test_analysis_identifiers07(username, sqlite_with_atlas): +def test_analysis_identifiers07(username, sqlite_with_atlas, configuration): with pytest.raises( traitlets.traitlets.TraitError, match="Parameter 'experiment' should contains 9 fields when split on '_', but has", @@ -103,52 +100,63 @@ def test_analysis_identifiers07(username, sqlite_with_atlas): AnalysisIdentifiers( source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", experiment="experiment_name_not_valid", - output_type="FinalEMA-HILIC", polarity="positive", analysis_number=0, google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", project_directory="/foo/bar", + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) -def test_analysis_identifiers08(username, sqlite_with_atlas, caplog, mocker, lcmsrun): +def test_analysis_identifiers08(username, sqlite_with_atlas, caplog, mocker, lcmsrun, configuration): mocker.patch("metatlas.plots.dill2plots.get_metatlas_files", return_value=[lcmsrun]) AnalysisIdentifiers( - source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", + source_atlas="HILICz150_ANT20190824_PRD_EMA_Unlab_POS", experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_EXTRA-FIELD", - output_type="FinalEMA-HILIC", polarity="positive", analysis_number=0, google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", project_directory=str(os.getcwd()), + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) assert "Parameter 'experiment' should contains 9 fields when split on '_', but has 10." in caplog.text -def test_analysis_identifiers09(sqlite_with_atlas, username, mocker, lcmsrun, groups_controlled_vocab): +def test_analysis_identifiers09( + sqlite_with_atlas, username, mocker, lcmsrun, groups_controlled_vocab, configuration +): mocker.patch("metatlas.plots.dill2plots.get_metatlas_files", return_value=[lcmsrun]) experiment = "20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583" - output_type = "FinalEMA-HILIC" polarity = "positive" analysis_number = 0 google_folder = "0B-ZDcHbPi-aqZzE5V3hOZFc0dms" AnalysisIdentifiers( - str(os.getcwd()), - experiment, - output_type, - polarity, - analysis_number, - google_folder, - source_atlas=f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", + project_directory=str(os.getcwd()), + experiment=experiment, + polarity=polarity, + analysis_number=analysis_number, + google_folder=google_folder, + source_atlas="HILICz150_ANT20190824_PRD_EMA_Unlab_POS", groups_controlled_vocab=groups_controlled_vocab, + configuration=configuration, + workflow="JGI-HILIC", + analysis="EMA-POS", ) def test_analysis_identifiers_atlas01(analysis_ids, username): - assert analysis_ids.atlas == f"505892_OakGall_final_FinalEMA-HILIC_POS_{username}_0_0" + assert ( + analysis_ids.atlas == f"505892_OakGall_final_HILICz150_ANT20190824_PRD_EMA_Unlab_POS_{username}_0_0" + ) def test_analysis_identifiers_atlas02(analysis_ids, username): # call .atlas twice to get cached value analysis_ids.atlas # pylint: disable=pointless-statement - assert analysis_ids.atlas == f"505892_OakGall_final_FinalEMA-HILIC_POS_{username}_0_0" + assert ( + analysis_ids.atlas == f"505892_OakGall_final_HILICz150_ANT20190824_PRD_EMA_Unlab_POS_{username}_0_0" + ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 00000000..eb9ffadd --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,14 @@ +"""Test configuration""" +import pytest + +from metatlas.tools.config import Config, Workflow + + +def test_duplicate_workflow_names(): + with pytest.raises(ValueError): + Config.parse_obj({"workflows": [{"name": "foo"}, {"name": "foo"}]}) + + +def test_workflow_name_restrictions(): + with pytest.raises(ValueError): + Workflow.parse_obj({"atlas_name": "x", "atlas_username": "y", "name": "Cannot have spaces"}) diff --git a/tests/unit/test_datastructure_utils.py b/tests/unit/test_datastructure_utils.py index d15cc32c..96bbd90c 100644 --- a/tests/unit/test_datastructure_utils.py +++ b/tests/unit/test_datastructure_utils.py @@ -5,7 +5,7 @@ def test_get_atlas01(sqlite_with_atlas, username): - query = f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0" + query = "HILICz150_ANT20190824_PRD_EMA_Unlab_POS" atlas = get_atlas(query, username) assert atlas.name == query assert len(atlas.compound_identifications) == 1 diff --git a/tests/unit/test_dill2plot.py b/tests/unit/test_dill2plot.py index dee1226c..b405ee57 100644 --- a/tests/unit/test_dill2plot.py +++ b/tests/unit/test_dill2plot.py @@ -78,6 +78,22 @@ def test_remove_metatlas_objects_by_list_remove_all(): assert [] == dill2plots.remove_metatlas_objects_by_list([i, j], "myattr", [0, 2, 5]) +def test_remove_metatlas_objects_by_list01(): + i = type("", (), {})() + i.myattr = "foobar" + j = type("", (), {})() + j.myattr = "zoop!" + assert [] == dill2plots.remove_metatlas_objects_by_list([i, j], "myattr", ["oo"]) + + +def test_remove_metatlas_objects_by_list02(): + i = type("", (), {})() + i.myattr = "foobar" + j = type("", (), {})() + j.myattr = "zoop!" + assert [j] == dill2plots.remove_metatlas_objects_by_list([i, j], "myattr", ["bar"]) + + def test_export_atlas_to_spreadsheet(atlas, username): # pylint: disable=line-too-long expected = ( diff --git a/tests/unit/test_metatlas_dataset.py b/tests/unit/test_metatlas_dataset.py index acd8b662..4a153c89 100644 --- a/tests/unit/test_metatlas_dataset.py +++ b/tests/unit/test_metatlas_dataset.py @@ -2,7 +2,6 @@ # pylint: disable=missing-function-docstring,protected-access,unused-argument,too-many-arguments import datetime -import glob import logging import os import time @@ -11,7 +10,6 @@ import pytest import traitlets -from metatlas.datastructures import analysis_identifiers from metatlas.datastructures import metatlas_dataset as mads from metatlas.datastructures import metatlas_objects as metob from metatlas.datastructures import object_helpers as metoh @@ -430,14 +428,17 @@ def test_write_data_source_files02(metatlas_dataset, mocker, caplog): assert ma_data.make_data_sources_tables.called # pylint: disable=no-member -def test_get_atlas01(mocker, analysis_ids, df_container, lcmsrun, atlas, username): +def test_get_atlas01(mocker, analysis_ids, df_container, lcmsrun, username): mocker.patch( "metatlas.io.metatlas_get_data_helper_fun.df_container_from_metatlas_file", return_value=df_container ) mocker.patch("metatlas.plots.dill2plots.get_metatlas_files", return_value=[lcmsrun]) mocker.patch("glob.glob", return_value=range(10)) metatlas_dataset = mads.MetatlasDataset(ids=analysis_ids) - assert metatlas_dataset.atlas.name == f"505892_OakGall_final_FinalEMA-HILIC_POS_{username}_0_0" + assert ( + metatlas_dataset.atlas.name + == f"505892_OakGall_final_HILICz150_ANT20190824_PRD_EMA_Unlab_POS_{username}_0_0" + ) def test_get_atlas02(mocker, analysis_ids, caplog): @@ -452,7 +453,7 @@ def test_get_atlas03(mocker, analysis_ids, caplog, username): mocker.patch("metatlas.datastructures.metatlas_objects.retrieve", return_value=[0, 0]) with pytest.raises(ValueError): mads.MetatlasDataset(ids=analysis_ids) - atlas = f"505892_OakGall_final_FinalEMA-HILIC_POS_{username}_0_0" + atlas = f"505892_OakGall_final_HILICz150_ANT20190824_PRD_EMA_Unlab_POS_{username}_0_0" assert f"2 atlases with name {atlas} and owned by {username} already exist." in caplog.text @@ -490,38 +491,6 @@ def group(): metatlas_dataset.ids.store_all_groups() -def test_annotation_gui01(metatlas_dataset, hits, mocker, instructions): - mocker.patch("metatlas.plots.dill2plots.get_msms_hits", return_value=hits) - mocker.patch("pandas.read_csv", return_value=instructions) - agui = metatlas_dataset.annotation_gui() - agui.compound_idx = 0 - agui.set_msms_flag("1, co-isolated precursor but all reference ions are in sample spectrum") - agui.set_peak_flag("remove") - agui.data.set_rt(0, "rt_min", 2.1245) - agui.data.set_rt(0, "rt_max", 2.4439) - assert metatlas_dataset.rts[0].rt_min == 2.1245 - assert metatlas_dataset.rts[0].rt_max == 2.4439 - assert metatlas_dataset.data[0][0]["identification"].ms1_notes == "remove" - assert ( - metatlas_dataset.data[0][0]["identification"].ms2_notes - == "1, co-isolated precursor but all reference ions are in sample spectrum" - ) - - -def test_annotation_gui02(metatlas_dataset, hits, mocker, instructions): - metatlas_dataset[0][0]["identification"].compound = [] - mocker.patch("metatlas.plots.dill2plots.get_msms_hits", return_value=hits) - mocker.patch("pandas.read_csv", return_value=instructions) - metatlas_dataset.annotation_gui() - - -def test_generate_all_outputs01(metatlas_dataset, hits, mocker): - mocker.patch("metatlas.plots.dill2plots.get_msms_hits", return_value=hits) - metatlas_dataset.generate_all_outputs() - assert len(glob.glob(metatlas_dataset.ids.output_dir + "/*")) == 15 - assert len(glob.glob(metatlas_dataset.ids.output_dir + "/*/*")) == 19 - - def test_short_polarity_inverse01(analysis_ids): assert set(analysis_ids.short_polarity_inverse) == {"NEG", "FPS"} @@ -556,34 +525,6 @@ def test_invalidation01(analysis_ids): assert analysis_ids._groups is None -def test_negative_polarity01(sqlite_with_atlas, username, lcmsrun, mocker, groups_controlled_vocab): - mocker.patch("metatlas.plots.dill2plots.get_metatlas_files", return_value=[lcmsrun]) - ids = analysis_identifiers.AnalysisIdentifiers( - experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="FinalEMA-HILIC", - polarity="negative", - analysis_number=0, - google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", - project_directory=str(os.getcwd()), - groups_controlled_vocab=groups_controlled_vocab, - ) - assert "POS" in ids.exclude_groups - - -def test_include_groups01(sqlite_with_atlas, username, lcmsrun, mocker, groups_controlled_vocab): - mocker.patch("metatlas.plots.dill2plots.get_metatlas_files", return_value=[lcmsrun]) - ids = analysis_identifiers.AnalysisIdentifiers( - experiment="20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583", - output_type="data_QC", - polarity="negative", - analysis_number=0, - google_folder="0B-ZDcHbPi-aqZzE5V3hOZFc0dms", - project_directory=str(os.getcwd()), - groups_controlled_vocab=groups_controlled_vocab, - ) - assert "QC" in ids.include_groups - - def test_project01(analysis_ids): assert analysis_ids.project == "505892" diff --git a/tests/unit/test_metatlas_get_data_helper_fun.py b/tests/unit/test_metatlas_get_data_helper_fun.py index da5b9016..8e4dea43 100644 --- a/tests/unit/test_metatlas_get_data_helper_fun.py +++ b/tests/unit/test_metatlas_get_data_helper_fun.py @@ -378,7 +378,7 @@ def test_get_data_for_atlas_df_and_file(lcmsrun, group, atlas_df, atlas, usernam result = gdhf.get_data_for_atlas_df_and_file((lcmsrun.hdf5_file, group, atlas_df, atlas)) expected = ( { - "atlas_name": f"HILICz150_ANT20190824_PRD_EMA_Unlab_POS_20201106_505892_{username}_0_0", + "atlas_name": "HILICz150_ANT20190824_PRD_EMA_Unlab_POS", "atlas_unique_id": "749354f7ad974b288624dad533dcbeec", "lcmsrun": "/project/projectdirs/metatlas/raw_data/akuftin/20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583/20201106_JGI-AK_PS-KM_505892_OakGall_final_QE-HF_HILICZ_USHXG01583_POS_MSMS_49_Cone-S1_1_Rg70to1050-CE102040-QlobataAkingi-S1_Run34.h5", "group": { diff --git a/tests/unit/test_metatlas_objects.py b/tests/unit/test_metatlas_objects.py index da17c4e3..40fb5886 100644 --- a/tests/unit/test_metatlas_objects.py +++ b/tests/unit/test_metatlas_objects.py @@ -262,3 +262,17 @@ def test_retrieve_head(): mo.store(test) new = len(mo.retrieve("lcmsrun", name="foo")) assert new == old + + +def test_adjust_atlas_rt_range01(atlas): + orig_rt_min = atlas.compound_identifications[0].rt_references[0].rt_min + mod_atlas = mo.adjust_atlas_rt_range(atlas, -0.1, 0.1) + mod_rt_min = mod_atlas.compound_identifications[0].rt_references[0].rt_min + assert orig_rt_min != mod_rt_min + + +def test_adjust_atlas_rt_range02(atlas): + orig_rt_max = atlas.compound_identifications[0].rt_references[0].rt_max + mod_atlas = mo.adjust_atlas_rt_range(atlas, -0.1, 0.1) + mod_rt_max = mod_atlas.compound_identifications[0].rt_references[0].rt_max + assert orig_rt_max != mod_rt_max diff --git a/tests/unit/test_predict_rt.py b/tests/unit/test_rt_alignment.py similarity index 54% rename from tests/unit/test_predict_rt.py rename to tests/unit/test_rt_alignment.py index ccab249f..a9b12428 100644 --- a/tests/unit/test_predict_rt.py +++ b/tests/unit/test_rt_alignment.py @@ -1,14 +1,14 @@ -""" unit testing of predict_rt functions """ +""" unit testing of rt_alignment functions """ # pylint: disable=missing-function-docstring import pandas as pd -from metatlas.tools import predict_rt +from metatlas.targeted import rt_alignment def test_get_rts01(metatlas_dataset): # pylint: disable=line-too-long - rts_df = predict_rt.get_rts(metatlas_dataset, include_atlas_rt_peak=False) + rts_df = rt_alignment.get_rts(metatlas_dataset, include_atlas_rt_peak=False) assert f"{rts_df.iloc[0]['min']:0.5f}" == "2.29224" assert ( rts_df.to_json() @@ -16,21 +16,7 @@ def test_get_rts01(metatlas_dataset): ) -def test_adjust_atlas_rt_range01(atlas): - orig_rt_min = atlas.compound_identifications[0].rt_references[0].rt_min - mod_atlas = predict_rt.adjust_atlas_rt_range(atlas, -0.1, 0.1) - mod_rt_min = mod_atlas.compound_identifications[0].rt_references[0].rt_min - assert orig_rt_min != mod_rt_min - - -def test_adjust_atlas_rt_range02(atlas): - orig_rt_max = atlas.compound_identifications[0].rt_references[0].rt_max - mod_atlas = predict_rt.adjust_atlas_rt_range(atlas, -0.1, 0.1) - mod_rt_max = mod_atlas.compound_identifications[0].rt_references[0].rt_max - assert orig_rt_max != mod_rt_max - - -def test_plot_actual_vs_pred_rts01(model): +def test_plot_actual_vs_aligned_rts01(model): arrays = [[]] rts_df = pd.DataFrame(data={"1": [], "2": [], "3": [], "4": [], "5": [], "6": []}) - predict_rt.plot_actual_vs_pred_rts(arrays, arrays, rts_df, "file_name", model, model) + rt_alignment.plot_actual_vs_aligned_rts(arrays, arrays, rts_df, "file_name", model, model) diff --git a/tests/unit/test_targeted_output.py b/tests/unit/test_targeted_output.py index 2e84743c..5dea8ed3 100644 --- a/tests/unit/test_targeted_output.py +++ b/tests/unit/test_targeted_output.py @@ -1,9 +1,13 @@ -""" unit testing of targeted_output functions """ -# pylint: disable=missing-function-docstring +""" tests for metatlas.targeted.process""" +# pylint: disable=missing-function-docstring,protected-access,unused-argument,too-many-arguments -from metatlas.io import targeted_output +import glob +from metatlas.io.targeted_output import generate_all_outputs -def test_write_msms_fragment_ions01(metatlas_dataset): - out = targeted_output.write_msms_fragment_ions(metatlas_dataset, min_mz=100, max_mz_offset=0.5) - assert out.loc[0, "spectrum"] == "[[252.11, 252.16], [100000, 7912]]" + +def test_generate_all_outputs01(metatlas_dataset, hits, mocker, analysis): + mocker.patch("metatlas.plots.dill2plots.get_msms_hits", return_value=hits) + generate_all_outputs(metatlas_dataset, analysis) + assert len(glob.glob(metatlas_dataset.ids.output_dir + "/*")) == 15 + assert len(glob.glob(metatlas_dataset.ids.output_dir + "/*/*")) == 19 diff --git a/tests/unit/test_targeted_process.py b/tests/unit/test_targeted_process.py new file mode 100644 index 00000000..a5692e8d --- /dev/null +++ b/tests/unit/test_targeted_process.py @@ -0,0 +1,30 @@ +""" tests for metatlas.targeted.process""" +# pylint: disable=missing-function-docstring,protected-access,unused-argument + + +from metatlas.targeted.process import annotation_gui + + +def test_annotation_gui01(metatlas_dataset, hits, mocker, instructions): + mocker.patch("metatlas.plots.dill2plots.get_msms_hits", return_value=hits) + mocker.patch("pandas.read_csv", return_value=instructions) + agui = annotation_gui(metatlas_dataset) + agui.compound_idx = 0 + agui.set_msms_flag("1, co-isolated precursor but all reference ions are in sample spectrum") + agui.set_peak_flag("remove") + agui.data.set_rt(0, "rt_min", 2.1245) + agui.data.set_rt(0, "rt_max", 2.4439) + assert metatlas_dataset.rts[0].rt_min == 2.1245 + assert metatlas_dataset.rts[0].rt_max == 2.4439 + assert metatlas_dataset.data[0][0]["identification"].ms1_notes == "remove" + assert ( + metatlas_dataset.data[0][0]["identification"].ms2_notes + == "1, co-isolated precursor but all reference ions are in sample spectrum" + ) + + +def test_annotation_gui02(metatlas_dataset, hits, mocker, instructions): + metatlas_dataset[0][0]["identification"].compound = [] + mocker.patch("metatlas.plots.dill2plots.get_msms_hits", return_value=hits) + mocker.patch("pandas.read_csv", return_value=instructions) + annotation_gui(metatlas_dataset) diff --git a/utils/rclone_auth.sh b/utils/rclone_auth.sh index 8fab1db6..4139c074 100755 --- a/utils/rclone_auth.sh +++ b/utils/rclone_auth.sh @@ -2,17 +2,27 @@ set -euf -o pipefail -CMD=rclone -if ! which "$CMD" > /dev/null 2>&1; then - CMD="/global/cfs/cdirs/m342/USA/shared-envs/rclone/bin/rclone" +config_file="$HOME/.config/rclone/rclone.conf" + +cmd=rclone +if ! which "$cmd" > /dev/null 2>&1; then + cmd="/global/cfs/cdirs/m342/USA/shared-envs/rclone/bin/rclone" fi -"$CMD" config create --all metabolomics drive \ - config_change_team_drive false \ - config_is_local false \ - config_fs_advanced false \ - client_id "" \ - client_secret "" \ - root_folder_id "0B-ZDcHbPi-aqZzE5V3hOZFc0dms" \ - scope "" \ - service_account_file "" +# authorize connections to Google Drive and add a folder mapping +"$cmd" config create --all rclone_test drive \ + config_change_team_drive false \ + config_is_local false \ + config_fs_advanced false \ + client_id "" \ + client_secret "" \ + root_folder_id "15F2RhHs4hXxPNoF_UUNPi5n16I0M7oCu" \ + scope "" \ + service_account_file "" + +# add an additional folder mapping on Google Drive without re-doing the authorization process +append="$(echo $'\n[JGI_Metabolomics_Projects]' && \ + grep -A8 "^\[rclone_test\]" "$config_file" | \ + tail -n +2 | \ + sed 's%^root_folder_id = .*%root_folder_id = 0B-ZDcHbPi-aqZzE5V3hOZFc0dms%' )" +echo "$append" >> "$config_file"