|
| 1 | +using System.Diagnostics.CodeAnalysis; |
| 2 | +using MzLibUtil; |
| 3 | + |
| 4 | +namespace Readers |
| 5 | +{ |
| 6 | + /// <summary> |
| 7 | + /// Interprets an SDRF cell's key=value grammar, e.g. |
| 8 | + /// "NT=Oxidation;AC=UNIMOD:35;TA=M;MT=Variable". |
| 9 | + /// |
| 10 | + /// This is the opt-in projection that <see cref="SdrfDocument"/> deliberately does not perform |
| 11 | + /// at read time. Cells are stored raw so that documents round-trip losslessly; anything that |
| 12 | + /// wants to reason about a cell's meaning asks for it here. |
| 13 | + /// |
| 14 | + /// Detection is by LEADING KEY, never by shape. A cell is only treated as a term if it starts |
| 15 | + /// with one of the recognised SDRF keys. Testing for "contains '=' and ';'" instead would |
| 16 | + /// misfire on real data: comment[file uri] cells in the curated corpus hold pre-signed download |
| 17 | + /// URLs whose query strings carry "Signature=", "Expires=" and "Id=" -- 5,750 occurrences each |
| 18 | + /// -- and a shape-based parser decodes those as controlled-vocabulary descriptors. |
| 19 | + /// </summary> |
| 20 | + internal static class SdrfCell |
| 21 | + { |
| 22 | + /// <summary> |
| 23 | + /// The keys the SDRF-Proteomics specification defines. This is the SOURCE OF TRUTH: |
| 24 | + /// <see cref="KnownLeadingKeys"/> is a subset of <see cref="KnownKeys"/>, pinned by |
| 25 | + /// LeadingKeysAreASubsetOfKnownKeys in the test project, so a key cannot enter the grammar |
| 26 | + /// through the leading-key set without first being justified here. |
| 27 | + /// |
| 28 | + /// NT name, AC accession, MT modification type, TA target amino acid, PP position, |
| 29 | + /// VV version value, CT compound type, QY quantity, PS peptide sequence, SP species, |
| 30 | + /// CN common name, CV vendor, CL cleavable, MH/ML stub mass high/low, SN source name. |
| 31 | + /// |
| 32 | + /// SN is genuinely specified, which is easy to doubt because it is absent from the |
| 33 | + /// modification-style key list: it belongs to characteristics[pooled sample], where |
| 34 | + /// "SN=sample1;SN=sample2" lists the source names of a pool (specification README.adoc |
| 35 | + /// lines 352 and 362, TERMS.tsv "pooled sample"). The corpus agrees -- all 383 leading SN |
| 36 | + /// cells sit in characteristics[pooled sample]. |
| 37 | + /// </summary> |
| 38 | + private static readonly HashSet<string> SpecKeys = new(StringComparer.OrdinalIgnoreCase) |
| 39 | + { |
| 40 | + "NT", "AC", "MT", "TA", "PP", "VV", "CT", "QY", |
| 41 | + "PS", "SP", "CN", "CV", "CL", "MH", "ML", "SN" |
| 42 | + }; |
| 43 | + |
| 44 | + /// <summary> |
| 45 | + /// Keys the corpus uses that the specification does not define, kept because they are |
| 46 | + /// widespread and unambiguous in context -- MM monoisotopic mass (26,828 occurrences), |
| 47 | + /// CS cleavage site (3,810), CF chemical formula (2,602), all inside modification and |
| 48 | + /// cleavage-agent cells. |
| 49 | + /// |
| 50 | + /// Separated from <see cref="SpecKeys"/> rather than merged so that the distinction between |
| 51 | + /// "the format says so" and "the community writes it anyway" stays visible. Anything added |
| 52 | + /// here is a tolerance, and tolerances belong on the reading side only -- never in authored |
| 53 | + /// output. |
| 54 | + /// </summary> |
| 55 | + private static readonly HashSet<string> ObservedNonSpecKeys = new(StringComparer.OrdinalIgnoreCase) |
| 56 | + { |
| 57 | + "MM", "CS", "CF" |
| 58 | + }; |
| 59 | + |
| 60 | + /// <summary> |
| 61 | + /// Every key this reader will interpret: the specified ones plus the tolerated ones. |
| 62 | + /// |
| 63 | + /// "PMID" was removed. It is not a key in any position -- the specification uses PMID only |
| 64 | + /// as a VALUE format for reference columns ("URL, DOI, or PMID"), and the 3 corpus |
| 65 | + /// occurrences are of that kind. "N" was removed for the reasons given on |
| 66 | + /// <see cref="KnownLeadingKeys"/>. "PS" was added: it is specified (spiked-compound cells, |
| 67 | + /// "CT=peptide;PS=PEPTIDESEQ;QY=10 fmol") though no corpus file uses it yet. |
| 68 | + /// </summary> |
| 69 | + internal static readonly HashSet<string> KnownKeys = |
| 70 | + new(SpecKeys.Concat(ObservedNonSpecKeys), StringComparer.OrdinalIgnoreCase); |
| 71 | + |
| 72 | + /// <summary> |
| 73 | + /// Keys that actually appear FIRST in a corpus cell: NT (1,459,508 cells in all 1,236 |
| 74 | + /// files), AC (190,369 in 323), CT (389 in 6) and SN (383 in 4). Everything else in |
| 75 | + /// <see cref="KnownKeys"/> only ever appears after a semicolon. |
| 76 | + /// |
| 77 | + /// "A" was removed from the set entirely. It is a single character, it never leads a real |
| 78 | + /// cell, and as a leading key it can only ever produce false positives -- any free-text |
| 79 | + /// value beginning "A=" would have been decoded as a controlled-vocabulary term. |
| 80 | + /// |
| 81 | + /// "N" was removed for exactly the same reasons, and the specification settles it: the key |
| 82 | + /// grammar has no "N", and NT is the only name key. It leads 45 corpus cells -- 27 in |
| 83 | + /// PXD039582 and 18 in PXD039585, every one of them "N=Orbitrap" in |
| 84 | + /// comment[ms2 analyzer type] -- which is a typo for NT= in two files by one submitter, not |
| 85 | + /// a spelling of the grammar. Accepting it decoded drift AS the grammar and so hid it from |
| 86 | + /// the one component whose job is to report it; SdrfDriftLint now sees those cells as the |
| 87 | + /// free text they are. An earlier revision of this comment listed the leading keys as |
| 88 | + /// "NT, AC, CT, SN, and N" with a count against every key except N, which is what made the |
| 89 | + /// reviewer look. |
| 90 | + /// </summary> |
| 91 | + internal static readonly HashSet<string> KnownLeadingKeys = new(StringComparer.OrdinalIgnoreCase) |
| 92 | + { |
| 93 | + "NT", "AC", "CT", "SN", "MT", "TA", "PP", "CS", "CF", "CL", "CV", "MM", "VV" |
| 94 | + }; |
| 95 | + |
| 96 | + /// <summary> |
| 97 | + /// Formats a controlled-vocabulary term as an SDRF cell: "NT=Oxidation;AC=UNIMOD:35". |
| 98 | + /// |
| 99 | + /// NT first, then AC, then any extra keys in the order given. This is a specification MUST, |
| 100 | + /// not merely a convention read off the corpus: "The key order MUST be NT (name) first, |
| 101 | + /// followed by AC (accession), then any additional keys" (README.adoc:261). The corpus |
| 102 | + /// agrees overwhelmingly anyway -- NT leads 1,459,508 cells, AC 190,369. |
| 103 | + /// |
| 104 | + /// The accession is written EXACTLY as supplied. This deliberately does not "helpfully" |
| 105 | + /// upper-case or add a missing prefix: the corpus is full of drift the caller should not be |
| 106 | + /// able to launder through here -- bare "4" for UNIMOD:4 in 39 documents, "Unimod:35" in 22, |
| 107 | + /// bare "1001251" for Trypsin in 39. Authored terms are meant to come from the pinned |
| 108 | + /// vocabulary already correct; silently repairing a wrong one here would hide the bug and |
| 109 | + /// make the drift lint's job impossible. |
| 110 | + /// </summary> |
| 111 | + /// <param name="term">The term. Name and Accession may not both be empty.</param> |
| 112 | + /// <param name="extras"> |
| 113 | + /// Additional key=value pairs in document order, e.g. ("TA","M"), ("MT","Variable"). |
| 114 | + /// Keys are emitted as given; a null or empty value is skipped. |
| 115 | + /// </param> |
| 116 | + public static string ToCell(CvParam term, params (string Key, string Value)[] extras) |
| 117 | + { |
| 118 | + if (term is null) throw new ArgumentNullException(nameof(term)); |
| 119 | + if (string.IsNullOrEmpty(term.Name) && string.IsNullOrEmpty(term.Accession)) |
| 120 | + throw new ArgumentException( |
| 121 | + "A controlled-vocabulary term needs at least a name or an accession.", nameof(term)); |
| 122 | + |
| 123 | + var parts = new List<string>(2 + (extras?.Length ?? 0)); |
| 124 | + if (!string.IsNullOrEmpty(term.Name)) parts.Add("NT=" + term.Name); |
| 125 | + if (!string.IsNullOrEmpty(term.Accession)) parts.Add("AC=" + term.Accession); |
| 126 | + |
| 127 | + foreach (var (key, value) in extras ?? Array.Empty<(string, string)>()) |
| 128 | + { |
| 129 | + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) continue; |
| 130 | + parts.Add(key + "=" + value); |
| 131 | + } |
| 132 | + |
| 133 | + string cell = string.Join(";", parts); |
| 134 | + |
| 135 | + // A separator inside a value would silently invent or merge keys on read. There is no |
| 136 | + // escape mechanism, so the only honest response is to refuse. |
| 137 | + if (cell.IndexOf('\t') >= 0 || cell.IndexOf('\n') >= 0 || cell.IndexOf('\r') >= 0) |
| 138 | + throw new ArgumentException( |
| 139 | + $"An SDRF cell cannot contain a tab or newline; the format defines no escape " + |
| 140 | + $"mechanism. Offending term: '{cell}'.", nameof(term)); |
| 141 | + |
| 142 | + return cell; |
| 143 | + } |
| 144 | + |
| 145 | + /// <summary> |
| 146 | + /// True when the cell is written in the key=value grammar rather than being free text. |
| 147 | + /// </summary> |
| 148 | + public static bool IsTerm(string cell) |
| 149 | + { |
| 150 | + if (string.IsNullOrEmpty(cell)) return false; |
| 151 | + int equals = cell.IndexOf('='); |
| 152 | + if (equals <= 0) return false; |
| 153 | + int semicolon = cell.IndexOf(';'); |
| 154 | + if (semicolon >= 0 && semicolon < equals) return false; |
| 155 | + |
| 156 | + // Case-insensitive, matching ParseKeyValues. These disagreed before: the leading key was |
| 157 | + // compared Ordinal while every later key was compared OrdinalIgnoreCase, so |
| 158 | + // "NT=Ox;ac=UNIMOD:35" parsed but "nt=Ox;AC=UNIMOD:35" was silently free text. |
| 159 | + return KnownLeadingKeys.Contains(cell.Substring(0, equals).Trim()); |
| 160 | + } |
| 161 | + |
| 162 | + /// <summary> |
| 163 | + /// Splits a cell into its key=value pairs, preserving order and duplicates-last-wins. |
| 164 | + /// Returns an empty dictionary for free text. Keys are upper-cased for lookup; values keep |
| 165 | + /// their original casing and inner whitespace. |
| 166 | + /// </summary> |
| 167 | + public static IReadOnlyDictionary<string, string> ParseKeyValues(string cell) |
| 168 | + { |
| 169 | + var pairs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| 170 | + if (!IsTerm(cell)) return pairs; |
| 171 | + |
| 172 | + foreach (var part in cell.Split(';')) |
| 173 | + { |
| 174 | + int equals = part.IndexOf('='); |
| 175 | + if (equals <= 0) continue; |
| 176 | + string key = part.Substring(0, equals).Trim(); |
| 177 | + if (key.Length == 0) continue; |
| 178 | + pairs[key] = part.Substring(equals + 1).Trim(); |
| 179 | + } |
| 180 | + return pairs; |
| 181 | + } |
| 182 | + |
| 183 | + /// <summary> |
| 184 | + /// Reads the cell as a controlled-vocabulary term. False for free text. |
| 185 | + /// |
| 186 | + /// The CV label is derived from the accession prefix ("MS:1001911" -> "MS"), because SDRF |
| 187 | + /// cells do not carry one separately the way an mzML cvParam does. |
| 188 | + /// </summary> |
| 189 | + public static bool TryParseTerm(string cell, [MaybeNullWhen(false)] out CvParam term) |
| 190 | + { |
| 191 | + term = null; |
| 192 | + var pairs = ParseKeyValues(cell); |
| 193 | + if (pairs.Count == 0) return false; |
| 194 | + |
| 195 | + // NT is the ONLY name key. An earlier revision also accepted "N" as an alternative |
| 196 | + // spelling, on the strength of 45 corpus cells that write "N=Orbitrap"; the |
| 197 | + // specification defines no such key, so that was decoding one submitter's typo as |
| 198 | + // grammar. See KnownLeadingKeys. |
| 199 | + pairs.TryGetValue("NT", out string? name); |
| 200 | + pairs.TryGetValue("AC", out string? accession); |
| 201 | + |
| 202 | + // A cell in the key=value grammar with neither NT nor AC is NOT a controlled-vocabulary |
| 203 | + // term, and must not be promoted to one. |
| 204 | + // |
| 205 | + // An earlier revision fell back to CN/SN/CT/SP here, to stop such cells being reported |
| 206 | + // as free text. That was worse than the problem. ParseKeyValues is last-wins, and the |
| 207 | + // dominant real case is characteristics[pooled sample], where a cell carries up to 45 |
| 208 | + // repeated SN= keys ("SN=OSL.53E;SN=OSL.567;..."): the fallback produced a Name that was |
| 209 | + // one arbitrary member of a list, with an empty accession. Worse, callers branch on this |
| 210 | + // method, so promoted cells left the free-text index without entering the accession |
| 211 | + // index -- 538 cells became invisible to every kind of drift analysis at once. |
| 212 | + // |
| 213 | + // Returning false routes them to the free-text side, where their values are still |
| 214 | + // compared. IsTerm still reports true, so a caller that wants the raw pairs can ask |
| 215 | + // ParseKeyValues for them. |
| 216 | + if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(accession)) return false; |
| 217 | + |
| 218 | + accession ??= ""; |
| 219 | + name ??= ""; |
| 220 | + int colon = accession.IndexOf(':'); |
| 221 | + string cvLabel = colon > 0 ? accession.Substring(0, colon) : ""; |
| 222 | + |
| 223 | + term = new CvParam(cvLabel, accession, name, ""); |
| 224 | + return true; |
| 225 | + } |
| 226 | + } |
| 227 | +} |
0 commit comments