Skip to content

Commit fcf6438

Browse files
trishortstrishorts
authored andcommitted
feat(ProForma): register ProForma as a SequenceConversion format
mzLib gained a universal sequence-conversion framework in #1035 (Omics/SequenceConversion: Parser -> CanonicalSequence -> Serializer, with pluggable IModificationLookup). ProForma was built alongside it rather than inside it. This plugs Layer 2 into that framework instead of standing beside it. Adds, in Readers (which is where the TopDownProteomics SDK is referenced; Omics does not reference it): - ProFormaSequenceFormatSchema - describes the format to the service. - ProFormaSequenceParser - ProForma string -> CanonicalSequence. - ProFormaSequenceSerializer - CanonicalSequence -> ProForma string. - ProFormaSequenceConversion.RegisterWith(service) - explicit, idempotent registration. Deliberately NOT a module initializer: touching SequenceConversionService.Default constructs GlobalModificationLookup and loads the modification databases, and that cost belongs to callers who convert sequences, not to every consumer of Readers. Both delegate their string handling to Layer 1 (ProFormaReader/ProFormaWriter), and the serializer emits descriptors through the existing ProFormaConverter.BuildDescriptor (now internal), so ProForma emission has one implementation, not two. The payoff is interop for free: mzLib -> ProForma now converts a tool-specific name to a community accession ("PEPC[Common Fixed:Carbamidomethyl on C]TIDE" -> "PEPC[UNIMOD:4]TIDE") through Nic's mzLib parser and our serializer, with no bespoke converter. It also gives the framework a parser for the accession notation its UnimodSequenceSerializer already emits. The flat-subset boundary is explicit and fails loud. CanonicalSequence is a flat model - one mod per position, no groups, ranges, or delocalization - so tag groups, position ranges, unlocalized, labile, and global mods, and sequence ambiguity are reported as IncompatibleModifications rather than silently dropped. Layer 1 still round-trips all of them losslessly; that is why Layer 1 keeps its own richer AST and is not routed through the canonical IR. Layer 2's resolution logic is intentionally left in place rather than replaced by ModificationLookupBase: ProFormaConverter resolves accessions through an exact index and understands context-bearing motifs (the N-glycosylation sequon "Nxs"), whereas ModificationLookupBase.FilterByUnimodId matches accession ids by substring Contains and FilterByMotif compares the whole motif string. Those look like precision gaps worth raising upstream, not behavior to adopt. Tests: 16 new (ProFormaSequenceConversionTests) covering registration, accession and terminal and mass tags, mzLib->ProForma and ProForma round trips, the five unrepresentable constructs, handling modes, and CanParse not stealing mzLib's own syntax during auto-detection. Full mzLib suite green: 5177 passed / 0 failed (baseline 5161; +16 = exactly the new tests).
1 parent ece8f76 commit fcf6438

6 files changed

Lines changed: 662 additions & 1 deletion

File tree

mzLib/Readers/ProForma/ProFormaConverter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ public static Tdp.ProFormaTerm ToProFormaTerm(string baseSequence,
139139
/// Emits an accession (Identifier) descriptor like <c>[UNIMOD:35]</c> when the modification
140140
/// carries a recognized ontology reference, otherwise a Name descriptor like <c>[Oxidation]</c>.
141141
/// </summary>
142-
private static Tdp.ProFormaDescriptor BuildDescriptor(Modification mod)
142+
internal static Tdp.ProFormaDescriptor BuildDescriptor(Modification mod)
143143
{
144144
if (mod.DatabaseReference != null)
145145
{
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using Omics.SequenceConversion;
2+
3+
namespace Readers.ProForma
4+
{
5+
/// <summary>
6+
/// Registers ProForma 2.0 as a source and target format with a
7+
/// <see cref="SequenceConversionService"/>.
8+
/// <para>
9+
/// ProForma lives in <c>Readers</c> rather than <c>Omics</c> because it needs the
10+
/// TopDownProteomics SDK, which <c>Omics</c> does not reference; registration is therefore done at
11+
/// runtime, which <see cref="SequenceConversionService"/> supports by design. It is deliberately
12+
/// explicit rather than a module initializer: touching
13+
/// <see cref="SequenceConversionService.Default"/> loads the modification databases, and that cost
14+
/// should be paid by callers who convert sequences, not by every consumer of <c>Readers</c>.
15+
/// </para>
16+
/// <example>
17+
/// <code>
18+
/// ProFormaSequenceConversion.RegisterWithDefault();
19+
/// var proForma = SequenceConversionService.Default.Convert(
20+
/// "PEP[Oxidation on M]TIDE", sourceFormat: "mzLib", targetFormat: "ProForma");
21+
/// </code>
22+
/// </example>
23+
/// </summary>
24+
public static class ProFormaSequenceConversion
25+
{
26+
private static readonly object RegistrationLock = new();
27+
private static bool _registeredWithDefault;
28+
29+
/// <summary>
30+
/// Registers the ProForma parser, serializer, and the converters that pair ProForma with the
31+
/// formats already present on <paramref name="service"/>.
32+
/// </summary>
33+
public static void RegisterWith(SequenceConversionService service)
34+
{
35+
ArgumentNullException.ThrowIfNull(service);
36+
37+
service.RegisterParser(ProFormaSequenceParser.Instance);
38+
service.RegisterSerializer(ProFormaSequenceSerializer.Instance);
39+
40+
// ProForma -> ProForma is the canonicalizing round trip; the cross-format pairs let the
41+
// service convert without synthesizing a converter on demand.
42+
service.RegisterConverter(new SequenceConverter(ProFormaSequenceParser.Instance, ProFormaSequenceSerializer.Instance));
43+
service.RegisterConverter(new SequenceConverter(MzLibSequenceParser.Instance, ProFormaSequenceSerializer.Instance));
44+
service.RegisterConverter(new SequenceConverter(ProFormaSequenceParser.Instance, MzLibSequenceSerializer.Instance));
45+
}
46+
47+
/// <summary>
48+
/// Registers ProForma with <see cref="SequenceConversionService.Default"/>. Idempotent, so it
49+
/// is safe to call from every entry point that needs ProForma conversion.
50+
/// </summary>
51+
public static void RegisterWithDefault()
52+
{
53+
if (_registeredWithDefault)
54+
return;
55+
56+
lock (RegistrationLock)
57+
{
58+
if (_registeredWithDefault)
59+
return;
60+
61+
RegisterWith(SequenceConversionService.Default);
62+
_registeredWithDefault = true;
63+
}
64+
}
65+
}
66+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using Omics.SequenceConversion;
2+
3+
namespace Readers.ProForma
4+
{
5+
/// <summary>
6+
/// Syntax description of HUPO-PSI ProForma 2.0 for the <see cref="SequenceConversionService"/>.
7+
/// <para>
8+
/// ProForma is not a bracket-tokenizable format in the sense the <c>SequenceParserBase</c> /
9+
/// <c>SequenceSerializerBase</c> helpers assume, so the ProForma parser and serializer implement
10+
/// <see cref="ISequenceParser"/> / <see cref="ISequenceSerializer"/> directly and delegate the
11+
/// string handling to Layer 1 (the TopDownProteomics reference parser/writer). This schema is
12+
/// therefore descriptive — it tells the framework what ProForma looks like; it is not the thing
13+
/// that parses it.
14+
/// </para>
15+
/// </summary>
16+
public sealed class ProFormaSequenceFormatSchema : SequenceFormatSchema
17+
{
18+
/// <summary>The format key this schema registers under.</summary>
19+
public const string ProFormaFormatName = "ProForma";
20+
21+
public static ProFormaSequenceFormatSchema Instance { get; } = new();
22+
23+
private ProFormaSequenceFormatSchema()
24+
: base(modOpen: '[', modClosed: ']', nTermSeparator: "-", cTermSeparator: "-")
25+
{
26+
}
27+
28+
/// <inheritdoc />
29+
public override string FormatName => ProFormaFormatName;
30+
}
31+
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
using System.Globalization;
2+
using Omics.SequenceConversion;
3+
using Tdp = TopDownProteomics.ProForma;
4+
5+
namespace Readers.ProForma
6+
{
7+
/// <summary>
8+
/// Parses a ProForma 2.0 string into a <see cref="CanonicalSequence"/>, making ProForma a
9+
/// first-class source format for <see cref="SequenceConversionService"/>.
10+
/// <para>
11+
/// The string handling is delegated to Layer 1 (<see cref="ProFormaReader"/>), which is lossless.
12+
/// The lossy step is Layer 1 -&gt; canonical: <see cref="CanonicalSequence"/> is a flat model
13+
/// (one modification per position, no groups/ranges/delocalization), so ProForma constructs that
14+
/// cannot be expressed in it — tag groups, position ranges, unlocalized, labile, and global
15+
/// modifications, and sequence ambiguity — are reported as incompatible rather than silently
16+
/// dropped. Use Layer 1 directly when losslessness matters.
17+
/// </para>
18+
/// </summary>
19+
public sealed class ProFormaSequenceParser : ISequenceParser
20+
{
21+
public static ProFormaSequenceParser Instance { get; } = new();
22+
23+
private ProFormaSequenceParser() { }
24+
25+
/// <inheritdoc />
26+
public string FormatName => ProFormaSequenceFormatSchema.ProFormaFormatName;
27+
28+
/// <inheritdoc />
29+
public SequenceFormatSchema Schema => ProFormaSequenceFormatSchema.Instance;
30+
31+
/// <summary>
32+
/// Fast heuristic used by <c>ParseAutoDetect</c>. Deliberately conservative: a bare sequence
33+
/// and mzLib's own <c>"[Type:Name on X]"</c> syntax are both left to the mzLib parser, so this
34+
/// only claims strings carrying a ProForma-distinctive token that also parse and fit the flat
35+
/// canonical model.
36+
/// </summary>
37+
public bool CanParse(string input)
38+
{
39+
if (string.IsNullOrWhiteSpace(input) || !LooksLikeProForma(input))
40+
return false;
41+
42+
try
43+
{
44+
return DescribeUnrepresentable(ProFormaReader.Read(input)) is null;
45+
}
46+
catch (Tdp.ProFormaParseException)
47+
{
48+
return false;
49+
}
50+
}
51+
52+
/// <inheritdoc />
53+
public CanonicalSequence? Parse(
54+
string input,
55+
ConversionWarnings? warnings = null,
56+
SequenceConversionHandlingMode mode = SequenceConversionHandlingMode.ThrowException)
57+
{
58+
warnings ??= new ConversionWarnings();
59+
60+
if (string.IsNullOrWhiteSpace(input))
61+
{
62+
return SequenceConversionHelpers.HandleParserError(warnings, mode,
63+
ConversionFailureReason.InvalidSequence, "ProForma input is null or empty.");
64+
}
65+
66+
Tdp.ProFormaTerm term;
67+
try
68+
{
69+
term = ProFormaReader.Read(input);
70+
}
71+
catch (Tdp.ProFormaParseException ex)
72+
{
73+
return SequenceConversionHelpers.HandleParserError(warnings, mode,
74+
ConversionFailureReason.InvalidSequence,
75+
$"'{input}' is not valid ProForma 2.0: {ex.Message}");
76+
}
77+
78+
var unrepresentable = DescribeUnrepresentable(term);
79+
if (unrepresentable is not null)
80+
{
81+
warnings.AddIncompatibleItem(unrepresentable);
82+
return SequenceConversionHelpers.HandleParserError(warnings, mode,
83+
ConversionFailureReason.IncompatibleModifications,
84+
$"ProForma term '{input}' cannot be represented as a CanonicalSequence: {unrepresentable}. " +
85+
"Use the ProForma reader (Layer 1) directly to work with this term losslessly.");
86+
}
87+
88+
var builder = new CanonicalSequenceBuilder(term.Sequence)
89+
.WithSourceFormat(FormatName);
90+
91+
if (term.NTerminalDescriptors is { Count: > 0 })
92+
{
93+
var (representation, unimodId, mass) = Describe(term.NTerminalDescriptors, warnings, term);
94+
builder.AddNTerminalModification(representation, mass, formula: null, unimodId: unimodId);
95+
}
96+
97+
if (term.Tags is not null)
98+
{
99+
foreach (var tag in term.Tags)
100+
{
101+
var (representation, unimodId, mass) = Describe(tag.Descriptors, warnings, term);
102+
builder.AddResidueModification(tag.ZeroBasedStartIndex, representation, mass,
103+
formula: null, unimodId: unimodId);
104+
}
105+
}
106+
107+
if (term.CTerminalDescriptors is { Count: > 0 })
108+
{
109+
var (representation, unimodId, mass) = Describe(term.CTerminalDescriptors, warnings, term);
110+
builder.AddCTerminalModification(representation, mass, formula: null, unimodId: unimodId);
111+
}
112+
113+
// The flat model allows only one modification per position; ProForma does not.
114+
var errors = builder.Validate();
115+
if (errors.Count > 0)
116+
{
117+
foreach (var error in errors)
118+
warnings.AddIncompatibleItem(error);
119+
120+
return SequenceConversionHelpers.HandleParserError(warnings, mode,
121+
ConversionFailureReason.IncompatibleModifications,
122+
$"ProForma term '{input}' does not fit the canonical model: {string.Join(" ", errors)}");
123+
}
124+
125+
return builder.Build();
126+
}
127+
128+
/// <summary>
129+
/// Names the first ProForma construct in <paramref name="term"/> that the flat
130+
/// <see cref="CanonicalSequence"/> model cannot hold, or null when the term is representable.
131+
/// This is the Layer-1 → canonical boundary, stated explicitly so callers fail loud.
132+
/// </summary>
133+
internal static string? DescribeUnrepresentable(Tdp.ProFormaTerm term)
134+
{
135+
if (term.TagGroups is { Count: > 0 })
136+
return "tag groups (crosslinks / localization groups)";
137+
if (term.LabileDescriptors is { Count: > 0 })
138+
return "labile modifications";
139+
if (term.UnlocalizedTags is { Count: > 0 })
140+
return "unlocalized modifications";
141+
if (term.GlobalModifications is { Count: > 0 })
142+
return "global modifications";
143+
144+
if (term.Tags is not null)
145+
{
146+
foreach (var tag in term.Tags)
147+
{
148+
if (tag.ZeroBasedStartIndex != tag.ZeroBasedEndIndex)
149+
return "position ranges";
150+
if (tag.HasAmbiguousSequence)
151+
return "sequence ambiguity";
152+
}
153+
}
154+
155+
return null;
156+
}
157+
158+
/// <summary>
159+
/// Collapses a ProForma descriptor list onto the single representation a
160+
/// <see cref="CanonicalModification"/> can hold, preferring an ontology accession (most
161+
/// resolvable) over a name over a mass. ProForma permits several descriptors on one tag
162+
/// (e.g. <c>[Phospho|UNIMOD:21]</c>); the canonical model does not, so the surplus is
163+
/// reported as a warning — the information is still intact in the Layer-1 term.
164+
/// </summary>
165+
private static (string Representation, int? UnimodId, double? Mass) Describe(
166+
IList<Tdp.ProFormaDescriptor> descriptors, ConversionWarnings warnings, Tdp.ProFormaTerm term)
167+
{
168+
Tdp.ProFormaDescriptor? preferred = null;
169+
int? unimodId = null;
170+
double? mass = null;
171+
172+
foreach (var descriptor in descriptors)
173+
{
174+
if (descriptor.Key == Tdp.ProFormaKey.Identifier)
175+
{
176+
preferred ??= descriptor;
177+
unimodId ??= TryGetUnimodId(descriptor.Value);
178+
}
179+
else if (descriptor.Key == Tdp.ProFormaKey.Mass
180+
&& double.TryParse(descriptor.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
181+
{
182+
mass ??= parsed;
183+
}
184+
}
185+
186+
preferred ??= descriptors.FirstOrDefault(d => d.Key == Tdp.ProFormaKey.Name)
187+
?? descriptors.FirstOrDefault();
188+
189+
if (descriptors.Count > 1)
190+
{
191+
warnings.AddWarning(
192+
$"ProForma tag [{string.Join("|", descriptors.Select(d => d.Value))}] in '{term.Sequence}' carries " +
193+
$"{descriptors.Count} descriptors; the canonical model keeps one ('{preferred?.Value}').");
194+
}
195+
196+
return (preferred?.Value ?? string.Empty, unimodId, mass);
197+
}
198+
199+
/// <summary>Extracts 35 from "UNIMOD:35". Returns null for any other ontology.</summary>
200+
private static int? TryGetUnimodId(string? value)
201+
{
202+
const string prefix = "UNIMOD:";
203+
if (value is null || !value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
204+
return null;
205+
206+
return int.TryParse(value.AsSpan(prefix.Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)
207+
? id
208+
: null;
209+
}
210+
211+
/// <summary>
212+
/// True when the string carries a token only ProForma uses. Keeps auto-detection from
213+
/// claiming plain sequences or mzLib's "Name on X" motif syntax.
214+
/// </summary>
215+
private static bool LooksLikeProForma(string input)
216+
{
217+
if (input.Contains(" on ", StringComparison.OrdinalIgnoreCase))
218+
return false;
219+
220+
if (input.IndexOfAny(['{', '<', '#']) >= 0)
221+
return true;
222+
223+
foreach (var ontology in Ontologies)
224+
{
225+
if (input.Contains(ontology, StringComparison.OrdinalIgnoreCase))
226+
return true;
227+
}
228+
229+
// A bare mass tag, e.g. PEP[+15.9949]TIDE.
230+
for (int i = 0; i < input.Length - 2; i++)
231+
{
232+
if (input[i] == '[' && (input[i + 1] == '+' || input[i + 1] == '-') && char.IsDigit(input[i + 2]))
233+
return true;
234+
}
235+
236+
return false;
237+
}
238+
239+
private static readonly string[] Ontologies =
240+
["UNIMOD:", "MOD:", "RESID:", "XLMOD:", "GNO:", "Obs:", "Info:", "Formula:", "Glycan:"];
241+
}
242+
}

0 commit comments

Comments
 (0)