Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
using CompMs.Common.Components;
using CompMs.Common.DataObj.Result;
using CompMs.Common.Enum;
using CompMs.MsdialCore.Algorithm.Annotation;
using CompMs.MsdialCore.DataObj;
using CompMs.MsdialCore.Utility;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;

namespace CompMs.MsdialCore.Export;

/// <summary>
/// Exports every annotation candidate MS-DIAL kept for an alignment spot, one row per candidate, rather
/// than only the representative one that the .mdalign columns carry.
/// </summary>
/// <remarks>
/// A product-ion spectrum reports structure only indirectly, so a search often cannot choose between
/// references whose spectra are indistinguishable. MS-DIAL already keeps the alternatives, up to
/// NUMBER_OF_ANNOTATION_RESULTS per annotator, and alignment carries them from the representative peak
/// into the spot. Publishing only the winner turns "the search could not decide" into "the search
/// decided", which is the kind of unearned certainty this sidecar exists to remove.
///
/// Conventions follow the sibling <see cref="AlignmentProvenanceExporter"/> rather than the .mdalign
/// text export, because the consumer is the same audit pipeline: tab separated, UTF-8 without a BOM, an
/// empty cell wherever the run established nothing, and full round-trip precision instead of the display
/// rounding of a spreadsheet column. A score that reads 0.977 in .mdalign therefore reads 0.977049112
/// here; they are the same measurement.
/// </remarks>
public sealed class AlignmentCandidateExporter
{
/// <summary>The value written wherever the run established nothing.</summary>
private const string NotApplicable = "";

private static readonly string[] Headers = [
"alignment_master_id",
"alignment_local_id",
"parent_alignment_id",
"candidate_rank",
"candidate_count",
"is_representative",
"annotator_id",
"database_id",
"source",
"priority",
"library_id",
"name",
"formula",
"ontology",
"inchikey",
"smiles",
"reference_mz",
"reference_rt_min",
"reference_adduct",
"annotation_tag_vs1",
"is_reference_matched",
"is_annotation_suggested",
"is_precursor_mz_match",
"is_spectrum_match",
"is_spectrum_comparison_performed",
"total_score",
"mz_similarity",
"rt_similarity",
"ri_similarity",
"ccs_similarity",
"isotope_similarity",
"simple_dot_product",
"weighted_dot_product",
"reverse_dot_product",
"matched_peaks_count",
"matched_peaks_percentage",
];

private readonly IMatchResultRefer<MoleculeMsReference?, MsScanMatchResult?>? _refer;
private readonly IReadOnlyDictionary<string, string> _databaseIdByAnnotator;
private readonly MachineCategory _machineCategory;

public AlignmentCandidateExporter(
IMatchResultRefer<MoleculeMsReference?, MsScanMatchResult?>? refer,
DataBaseStorage? databases,
MachineCategory machineCategory)
{
_refer = refer;
_databaseIdByAnnotator = databases is null
? new Dictionary<string, string>()
: AnnotationCandidates.DatabaseIdByAnnotator(databases);
_machineCategory = machineCategory;
}

public void Export(Stream stream, IEnumerable<AlignmentSpotProperty> spots)
{
using var writer = new StreamWriter(stream, new UTF8Encoding(false), 1024, leaveOpen: true);
WriteRow(writer, Headers);
foreach (var spot in Flatten(spots)) {
var candidates = AnnotationCandidates.Of(spot.MatchResults);
var representative = spot.MatchResults?.Representative;
for (int rank = 0; rank < candidates.Count; rank++) {
var candidate = candidates[rank];
WriteCandidate(writer, spot, candidate, rank + 1, candidates.Count, ReferenceEquals(candidate, representative));
}
}
}

private static IEnumerable<AlignmentSpotProperty> Flatten(IEnumerable<AlignmentSpotProperty> spots)
{
foreach (var spot in spots) {
yield return spot;
foreach (var driftSpot in spot.AlignmentDriftSpotFeatures ?? []) {
yield return driftSpot;
}
}
}

private void WriteCandidate(
StreamWriter writer,
AlignmentSpotProperty spot,
MsScanMatchResult candidate,
int rank,
int candidateCount,
bool isRepresentative)
{
var reference = _refer?.Refer(candidate);
var hadProductIonSpectrum = spot.IsMsmsAssigned;
// One decision, shared with the .mdalign and .mdpeak score columns: a spectral score exists only
// when a product-ion spectrum was actually compared against a reference spectrum. The rendering
// differs, an empty cell here against the text "null" there, but the condition must not.
var spectrumScored = AnnotationScoreFormat.IsComputed(candidate, hadProductIonSpectrum);
WriteRow(writer, [
spot.MasterAlignmentID.ToString(CultureInfo.InvariantCulture),
spot.AlignmentID.ToString(CultureInfo.InvariantCulture),
spot.ParentAlignmentID.ToString(CultureInfo.InvariantCulture),
rank.ToString(CultureInfo.InvariantCulture),
candidateCount.ToString(CultureInfo.InvariantCulture),
BooleanText(isRepresentative),
Sanitize(candidate.AnnotatorID),
DatabaseId(candidate),
SourceText(candidate.Source),
candidate.Priority.ToString(CultureInfo.InvariantCulture),
candidate.LibraryID.ToString(CultureInfo.InvariantCulture),
Sanitize(string.IsNullOrEmpty(candidate.Name) ? reference?.Name : candidate.Name),
Sanitize(reference?.Formula?.FormulaString),
Sanitize(string.IsNullOrEmpty(reference?.CompoundClass) ? reference?.Ontology : reference?.CompoundClass),
Sanitize(string.IsNullOrEmpty(candidate.InChIKey) ? reference?.InChIKey : candidate.InChIKey),
Sanitize(reference?.SMILES),
reference is null ? NotApplicable : Format(reference.PrecursorMz),
reference?.ChromXs is null ? NotApplicable : Format(reference.ChromXs.RT.Value),
Sanitize(reference?.AdductType?.AdductIonName),
DataAccess.GetAnnotationCode(candidate, _machineCategory).ToString(CultureInfo.InvariantCulture),
BooleanText(candidate.IsReferenceMatched),
BooleanText(candidate.IsAnnotationSuggested),
BooleanText(candidate.IsPrecursorMzMatch),
BooleanText(candidate.IsSpectrumMatch),
BooleanText(spectrumScored),
Format(candidate.TotalScore),
// The five similarity terms below have no equivalent of the -1 sentinel that marks an
// unattempted spectral comparison, so an unused term is stored as 0 and cannot be told apart
// from a term that was evaluated and scored 0. They are written as stored; a consumer that
// needs the distinction has to read which terms the run's parameter file enabled.
Format(candidate.AcurateMassSimilarity),
Format(candidate.RtSimilarity),
Format(candidate.RiSimilarity),
Format(candidate.CcsSimilarity),
Format(candidate.IsotopeSimilarity),
Score(spectrumScored, candidate.SimpleDotProduct),
Score(spectrumScored, candidate.WeightedDotProduct),
Score(spectrumScored, candidate.ReverseDotProduct),
Score(spectrumScored, candidate.MatchedPeaksCount),
Score(spectrumScored, candidate.MatchedPeaksPercentage),
]);
}

private string DatabaseId(MsScanMatchResult candidate)
=> candidate.AnnotatorID is string id && _databaseIdByAnnotator.TryGetValue(id, out var databaseId)
? Sanitize(databaseId)
: NotApplicable;

/// <summary>Writes one row, refusing a field count that does not match the header.</summary>
private static void WriteRow(StreamWriter writer, string[] values)
{
if (values.Length != Headers.Length) {
throw new InvalidOperationException(
$"Alignment candidate row has {values.Length} fields but the header declares {Headers.Length}.");
}
writer.WriteLine(string.Join("\t", values));
}

private static string Score(bool computed, float value)
=> computed ? Format(value) : NotApplicable;

/// <summary>
/// The stored score fields are Single, and G9 round-trips a Single exactly. The widening to double is
/// deliberate and matches <see cref="AnnotationScoreFormat"/>: .NET Framework formats a Single through
/// a 7-significant-digit intermediate, which shifts values that sit just below a rounding boundary.
/// </summary>
private static string Format(float value)
=> ((double)value).ToString("G9", CultureInfo.InvariantCulture);

private static string Format(double value)
=> value.ToString("G17", CultureInfo.InvariantCulture);

private static string SourceText(SourceType source)
=> source.ToString().Replace(" ", string.Empty);

private static string BooleanText(bool value) => value ? "true" : "false";

private static string Sanitize(string? value)
=> (value ?? string.Empty).Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ');
}
74 changes: 74 additions & 0 deletions src/MSDIAL5/MsdialCore/Export/AnnotationCandidates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using CompMs.Common.DataObj.Result;
using CompMs.MsdialCore.DataObj;
using System.Collections.Generic;
using System.Linq;

namespace CompMs.MsdialCore.Export;

/// <summary>
/// The one definition of which annotation candidates an export publishes for a peak spot, and of the
/// database each candidate's annotator drew from.
/// </summary>
/// <remarks>
/// MS-DIAL keeps up to <c>NUMBER_OF_ANNOTATION_RESULTS</c> threshold-passing results per annotator, but
/// every text export so far published only <see cref="MsScanMatchResultContainer.Representative"/>. The
/// alternatives were computed, carried through alignment, and then dropped at the file boundary, so a
/// reader could not tell an unambiguous match from one where the search did not choose between two
/// references it could not tell apart.
///
/// The order is <see cref="MsScanMatchResultContainer.TopResults"/>, which is the same order that picks
/// the representative: manual assignment, then reference match, then suggestion, then annotator
/// priority, then total score. A reference match with a lower annotator priority therefore still
/// outranks a higher-priority precursor-only suggestion, which is the intended scientific precedence.
/// </remarks>
public static class AnnotationCandidates
{
/// <summary>
/// The candidates of one spot or peak, best first, or an empty list when nothing was annotated.
/// </summary>
public static IReadOnlyList<MsScanMatchResult> Of(MsScanMatchResultContainer? results)
{
if (results is null) {
return [];
}
return results.TopResults.Where(IsPublishable).ToArray();
}

/// <summary>
/// True for a candidate that names a reference. Decoys are already excluded upstream by
/// <see cref="MsScanMatchResultContainer.TopResults"/>.
/// </summary>
/// <remarks>
/// Two things are deliberately not candidates. An empty container reports a single synthetic
/// unknown result, and "set unknown" in the GUI stores a real one, both carrying
/// <see cref="SourceType.Unknown"/>; neither names a molecule. A result that matched no database at
/// all carries no evidence to publish either. A manual assignment keeps the database bit it was
/// promoted from, so it stays a candidate and simply sorts first.
/// </remarks>
private static bool IsPublishable(MsScanMatchResult? result)
=> result is not null && !result.IsUnknown && result.AnyMatched;

/// <summary>
/// Maps each annotator identifier to the identifier of the database it searched.
/// </summary>
public static IReadOnlyDictionary<string, string> DatabaseIdByAnnotator(DataBaseStorage storage)
{
var map = new Dictionary<string, string>();
foreach (var db in storage.MetabolomicsDataBases) {
foreach (var pair in db.Pairs) {
map.Add(pair.AnnotatorID, db.DataBaseID);
}
}
foreach (var db in storage.ProteomicsDataBases) {
foreach (var pair in db.Pairs) {
map.Add(pair.AnnotatorID, db.DataBaseID);
}
}
foreach (var db in storage.EadLipidomicsDatabases) {
foreach (var pair in db.Pairs) {
map.Add(pair.AnnotatorID, db.DataBaseID);
}
}
return map;
}
}
20 changes: 16 additions & 4 deletions src/MSDIAL5/MsdialCore/Export/AnnotationScoreFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,27 @@ public static class AnnotationScoreFormat
/// </param>
/// <param name="format">A numeric format string, for example "F3".</param>
public static string Score(MsScanMatchResult? result, bool hadProductIonSpectrum, Func<MsScanMatchResult, double> value, string format) {
if (result is null || !result.IsSpectrumComparisonPerformed) {
return NotComputed;
}
if (!hadProductIonSpectrum && IsScoreBlockUnset(result)) {
if (!IsComputed(result, hadProductIonSpectrum)) {
return NotComputed;
}
return value(result).ToString(format);
}

/// <summary>
/// Whether the spectral score fields of <paramref name="result"/> hold measurements.
/// </summary>
/// <remarks>
/// Exposed separately because the audit sidecars render an absent value as an empty cell rather
/// than as the text "null" that the .mdpeak and .mdalign columns use. The decision of whether a
/// score exists must not fork with the rendering, so both go through this.
/// </remarks>
public static bool IsComputed(MsScanMatchResult? result, bool hadProductIonSpectrum) {
if (result is null || !result.IsSpectrumComparisonPerformed) {
return false;
}
return hadProductIonSpectrum || !IsScoreBlockUnset(result);
}

/// <summary>
/// True when every spectral score field still holds the default 0, so the block carries no
/// evidence that a comparison happened. A real comparison can also produce all zeros, which is
Expand Down
10 changes: 10 additions & 0 deletions src/MSDIAL5/MsdialCore/Export/IMetadataAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ public BaseMetadataAccessor(IMatchResultRefer<MoleculeMsReference?, MsScanMatchR
_trimSpectrumToExcelLimit = trimSpectrumToExcelLimit;
}
public ParameterBase Parameter => _parameter;

/// <summary>
/// The reference lookup this accessor resolves the representative match result through.
/// </summary>
/// <remarks>
/// Exposed for exports that describe candidates other than the representative one, which need the
/// same lookup applied to a different match result. <see cref="Parameter"/> is already read the
/// same way by the mzTab-M exporter.
/// </remarks>
public IMatchResultRefer<MoleculeMsReference?, MsScanMatchResult?>? Refer => _refer;
public string[] GetHeaders() => GetHeadersCore();

IReadOnlyDictionary<string, string> IMetadataAccessor.GetContent(AlignmentSpotProperty spot, IMSScanProperty msdec) {
Expand Down
Loading
Loading