|
4 | 4 | #' @return `data.table` |
5 | 5 | #' @keywords internal |
6 | 6 | .cleanRawSpectronaut = function(msstats_object, intensity, |
7 | | - calculateAnomalyScores, |
8 | | - anomalyModelFeatures) { |
| 7 | + calculateAnomalyScores, |
| 8 | + anomalyModelFeatures, |
| 9 | + peptideSequenceColumn = "EG.ModifiedSequence", |
| 10 | + heavyLabels = NULL) { |
9 | 11 | FFrgLossType = FExcludedFromQuantification = NULL |
10 | | - |
| 12 | + |
11 | 13 | spec_input = getInputFile(msstats_object, "input") |
12 | | - .validateSpectronautInput(spec_input) |
| 14 | + .validateSpectronautInput(spec_input, peptideSequenceColumn) |
| 15 | + spec_input = .addSpectronautColumnsIfMissing(spec_input) |
13 | 16 | spec_input = spec_input[FFrgLossType == "noloss", ] |
14 | | - |
15 | | - f_charge_col = .findAvailable(c("FCharge", "FFrgZ"), colnames(spec_input)) |
| 17 | + |
| 18 | + f_charge_col = .findAvailable(c("FCharge", "FFrgZ"), colnames(spec_input), fall_back = "FCharge") |
16 | 19 | pg_qval_col = .findAvailable(c("PGQvalue"), colnames(spec_input)) |
17 | | - interference_col = .findAvailable(c("FPossibleInterference"), |
| 20 | + interference_col = .findAvailable(c("FPossibleInterference"), |
18 | 21 | colnames(spec_input)) |
19 | | - exclude_col = .findAvailable(c("FExcludedFromQuantification"), |
| 22 | + exclude_col = .findAvailable(c("FExcludedFromQuantification"), |
20 | 23 | colnames(spec_input)) |
21 | | - intensity_column_mapping = c( |
22 | | - "PeakArea" = "FPeakArea", |
23 | | - "NormalizedPeakArea" = "FNormalizedPeakArea", |
24 | | - "MS1Quantity" = "FGMS1Quantity" |
25 | | - ) |
26 | | - intensity = match.arg(intensity, names(intensity_column_mapping)) |
27 | | - intensity_column = intensity_column_mapping[[intensity]] |
28 | | - cols = c("PGProteinGroups", "EGModifiedSequence", "FGCharge", "FFrgIon", |
29 | | - f_charge_col, "RFileName", "RCondition", "RReplicate", |
| 24 | + intensity_col = .resolveSpectronautIntensityColumn(intensity, colnames(spec_input)) |
| 25 | + peptide_col = .findAvailable(.standardizeColnames(peptideSequenceColumn), |
| 26 | + colnames(spec_input)) |
| 27 | + protein_col = .findAvailable(c("PGProteinGroups", "PGProteinAccessions"), |
| 28 | + colnames(spec_input), fall_back = "PGProteinGroups") |
| 29 | + |
| 30 | + cols = c(protein_col, peptide_col, "FGCharge", "FFrgIon", |
| 31 | + f_charge_col, "RFileName", "RCondition", "RReplicate", |
30 | 32 | "EGQvalue", pg_qval_col, interference_col, exclude_col, |
31 | | - intensity_column) |
| 33 | + intensity_col) |
32 | 34 | if (calculateAnomalyScores){ |
33 | 35 | cols = c(cols, anomalyModelFeatures) |
34 | 36 | } |
35 | 37 | cols = intersect(cols, colnames(spec_input)) |
36 | 38 | spec_input = spec_input[, cols, with = FALSE] |
| 39 | + |
37 | 40 | data.table::setnames( |
38 | | - spec_input, |
39 | | - c("PGProteinGroups", "EGModifiedSequence", "FGCharge", "FFrgIon", |
40 | | - f_charge_col, "RFileName", intensity_column, |
| 41 | + spec_input, |
| 42 | + c(protein_col, peptide_col, "FGCharge", "FFrgIon", |
| 43 | + f_charge_col, "RFileName", intensity_col, |
41 | 44 | "RCondition", "RReplicate"), |
42 | 45 | c("ProteinName", "PeptideSequence", "PrecursorCharge", "FragmentIon", |
43 | | - "ProductCharge", "Run", "Intensity", "Condition", "BioReplicate"), |
| 46 | + "ProductCharge", "Run", "Intensity", "Condition", "BioReplicate"), |
44 | 47 | skip_absent = TRUE) |
| 48 | + |
| 49 | + spec_input = .assignSpectronautIsotopeLabelType( |
| 50 | + spec_input, heavyLabels) |
| 51 | + |
45 | 52 | .logSuccess("Spectronaut", "clean") |
46 | 53 | spec_input |
47 | 54 | } |
48 | 55 |
|
49 | 56 | #' Helper method to validate input has necessary columns |
50 | 57 | #' @param spec_input dataframe input |
| 58 | +#' @param peptideSequenceColumn character, name of the column containing peptide |
| 59 | +#' sequences, passed from user |
51 | 60 | #' @noRd |
52 | | -.validateSpectronautInput = function(spec_input) { |
53 | | - required_columns = c( |
54 | | - "FFrgLossType", "FExcludedFromQuantification", "PGProteinGroups", |
55 | | - "FGCharge") |
| 61 | +.validateSpectronautInput = function(spec_input, peptideSequenceColumn) { |
| 62 | + # Only FGCharge is truly required |
| 63 | + required_columns = c("FGCharge") |
56 | 64 | missing_columns = setdiff(required_columns, colnames(spec_input)) |
57 | 65 | if (length(missing_columns) > 0) { |
58 | | - msg = paste("The following columns are missing from the input data:", |
| 66 | + msg = paste("The following columns are missing from the input data:", |
59 | 67 | paste(missing_columns, sep = ", ", collapse = ", ")) |
60 | 68 | getOption("MSstatsLog")("ERROR", msg) |
61 | 69 | stop(msg) |
62 | 70 | } |
| 71 | + # Ensure at least one protein name column is present |
| 72 | + if (!any(c("PGProteinGroups", "PGProteinAccessions") %in% colnames(spec_input))) { |
| 73 | + msg = paste("The following columns are missing from the input data:", |
| 74 | + "PGProteinGroups") |
| 75 | + getOption("MSstatsLog")("ERROR", msg) |
| 76 | + stop(msg) |
| 77 | + } |
| 78 | + # Ensure at least one protein name column is present |
| 79 | + if (!(.standardizeColnames(peptideSequenceColumn) %in% colnames(spec_input))) { |
| 80 | + msg = paste("The following column are missing from the input data:", |
| 81 | + peptideSequenceColumn) |
| 82 | + getOption("MSstatsLog")("ERROR", msg) |
| 83 | + stop(msg) |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +#' Add synthetic columns that are absent in protein turnover Spectronaut reports. |
| 88 | +#' |
| 89 | +#' Spectronaut's protein turnover export omits several columns that the |
| 90 | +#' standard MSstats pipeline expects. Rather than requiring callers to patch |
| 91 | +#' the data frame before passing it in (the "hacks" documented in the design |
| 92 | +#' document), we synthesize sensible defaults here so the rest of the pipeline |
| 93 | +#' sees a consistent schema. |
| 94 | +#' |
| 95 | +#' Columns synthesized when absent: |
| 96 | +#' \describe{ |
| 97 | +#' \item{FFrgLossType}{"noloss" — the filter \code{spec_input[FFrgLossType == "noloss"]} |
| 98 | +#' keeps all rows, which is the correct behavior when the column is missing.} |
| 99 | +#' \item{FExcludedFromQuantification}{FALSE — no rows are excluded.} |
| 100 | +#' \item{FFrgIon}{NA — fragment ion identity is not available at the precursor |
| 101 | +#' level used by MS1-based protein turnover workflows.} |
| 102 | +#' \item{FCharge}{NA — product charge is not applicable when fragment-level |
| 103 | +#' columns are absent.} |
| 104 | +#' } |
| 105 | +#' |
| 106 | +#' @param spec_input `data.table` with standardized column names. |
| 107 | +#' @return `data.table` with missing columns added. |
| 108 | +#' @keywords internal |
| 109 | +#' @noRd |
| 110 | +.addSpectronautColumnsIfMissing = function(spec_input) { |
| 111 | + if (!("FFrgLossType" %in% colnames(spec_input))) { |
| 112 | + spec_input[, FFrgLossType := "noloss"] |
| 113 | + } |
| 114 | + if (!("FExcludedFromQuantification" %in% colnames(spec_input))) { |
| 115 | + spec_input[, FExcludedFromQuantification := FALSE] |
| 116 | + } |
| 117 | + if (!("FFrgIon" %in% colnames(spec_input))) { |
| 118 | + spec_input[, FFrgIon := NA_character_] |
| 119 | + } |
| 120 | + if (!("FCharge" %in% colnames(spec_input))) { |
| 121 | + spec_input[, FCharge := NA_integer_] |
| 122 | + } |
| 123 | + spec_input |
| 124 | +} |
| 125 | + |
| 126 | + |
| 127 | +#' Resolve the Spectronaut intensity column from user input. |
| 128 | +#' |
| 129 | +#' Accepts either a legacy enum alias or a raw (standardized) column name. |
| 130 | +#' The legacy aliases map to their canonical standardized column names so that |
| 131 | +#' old code continues to work unchanged. When the user passes a raw column |
| 132 | +#' name (e.g. \code{"FGMS1Quantity"} or \code{"FGMS2Quantity"}), it is used |
| 133 | +#' directly after verifying that the column exists. |
| 134 | +#' |
| 135 | +#' @param intensity Character scalar: enum alias or raw column name. |
| 136 | +#' @param available_cols Character vector of available standardized column names. |
| 137 | +#' @return The resolved standardized column name. |
| 138 | +#' @keywords internal |
| 139 | +#' @noRd |
| 140 | +.resolveSpectronautIntensityColumn = function(intensity, available_cols) { |
| 141 | + legacy_mapping = c( |
| 142 | + "PeakArea" = "FPeakArea", |
| 143 | + "NormalizedPeakArea" = "FNormalizedPeakArea" |
| 144 | + ) |
| 145 | + |
| 146 | + if (intensity %in% names(legacy_mapping)) { |
| 147 | + resolved = legacy_mapping[[intensity]] |
| 148 | + } else { |
| 149 | + resolved = .standardizeColnames(intensity) |
| 150 | + } |
| 151 | + |
| 152 | + if (!(resolved %in% available_cols)) { |
| 153 | + stop(paste0( |
| 154 | + "Intensity column '", intensity, "' not found in input data. ", collapse = ", ") |
| 155 | + ) |
| 156 | + } |
| 157 | + resolved |
| 158 | +} |
| 159 | + |
| 160 | + |
| 161 | +#' Assign IsotopeLabelType based on heavy label detection. |
| 162 | +#' |
| 163 | +#' When \code{heavyLabel} is provided, each row is classified as heavy |
| 164 | +#' (\code{"H"}), light (\code{"L"}), or unlabeled (\code{NA}) by inspecting |
| 165 | +#' the labeled sequence column for the presence of the label tag. |
| 166 | +#' |
| 167 | +#' In Spectronaut protein turnover reports, heavy peptides appear in |
| 168 | +#' \code{FG.LabeledSequence} with a bracketed modification, e.g. |
| 169 | +#' \code{_PEPTIDEK[Lys6]_}. Any sequence that contains |
| 170 | +#' \code{[<heavyLabel>]} is classified as heavy; all others are light. |
| 171 | +#' Sequences that do not have amino acids that can carry the label |
| 172 | +#' are classified as \code{NA}. For example, if \code{heavyLabels} is |
| 173 | +#' \code{"Lys6"}, then \code{PEPTIDEZ} is classified as NA since it |
| 174 | +#' has no lysine residues that could be labeled. |
| 175 | +#' When \code{heavyLabel} is \code{NULL} the column is left untouched so |
| 176 | +#' that the downstream \code{columns_to_fill} default of \code{"L"} applies, |
| 177 | +#' preserving backwards compatibility. |
| 178 | +#' |
| 179 | +#' @param spec_input `data.table` after column renaming. |
| 180 | +#' @param heavyLabels Character scalar heavy label name (e.g. \code{"Lys6"}), |
| 181 | +#' or \code{NULL}. |
| 182 | +#' @return `data.table` with \code{IsotopeLabelType} column added or updated. |
| 183 | +#' @keywords internal |
| 184 | +#' @noRd |
| 185 | +.assignSpectronautIsotopeLabelType = function(spec_input, heavyLabels) { |
| 186 | + IsotopeLabelType = PeptideSequence = StrippedSequence = NULL |
| 187 | + if (is.null(heavyLabels)) { |
| 188 | + return(spec_input) |
| 189 | + } |
| 190 | + |
| 191 | + bare_amino_acids = sub("\\[.*\\]", "", heavyLabels) |
| 192 | + bare_amino_acids_pattern = paste0(bare_amino_acids, collapse = "|") |
| 193 | + heavy_pattern = paste0(heavyLabels, collapse = "|") |
| 194 | + heavy_brackets_escaped_pattern = paste( |
| 195 | + gsub("([\\[\\]])", "\\\\\\1", heavy_pattern, perl = TRUE), |
| 196 | + collapse = "|" |
| 197 | + ) |
| 198 | + |
| 199 | + spec_input[, StrippedSequence := gsub("\\[.*?\\]", "", PeptideSequence)] |
| 200 | + |
| 201 | + spec_input[, IsotopeLabelType := data.table::fcase( |
| 202 | + grepl(heavy_brackets_escaped_pattern, PeptideSequence, perl = TRUE), "H", |
| 203 | + grepl(bare_amino_acids_pattern, StrippedSequence, perl = TRUE), "L", |
| 204 | + default = NA_character_ |
| 205 | + )] |
| 206 | + |
| 207 | + spec_input[, StrippedSequence := NULL] |
| 208 | + |
| 209 | + for (i in seq_along(heavyLabels)) { |
| 210 | + escaped = gsub("([\\[\\]])", "\\\\\\1", heavyLabels[i], perl = TRUE) |
| 211 | + spec_input[, PeptideSequence := gsub(escaped, bare_amino_acids[i], PeptideSequence, perl = TRUE)] |
| 212 | + } |
| 213 | + spec_input |
63 | 214 | } |
0 commit comments