diff --git a/resources/queries/targetedms/UngroupedMissedCleavagePeptides.query.xml b/resources/queries/targetedms/UngroupedMissedCleavagePeptides.query.xml
new file mode 100644
index 000000000..fc0adef5e
--- /dev/null
+++ b/resources/queries/targetedms/UngroupedMissedCleavagePeptides.query.xml
@@ -0,0 +1,3 @@
+
+ Missed-cleavage peptides lacking a grouping. PTM reports show these as separate rows instead of combining them per site; set a shared Attribute Group ID in Skyline to merge them.
+
diff --git a/resources/queries/targetedms/UngroupedMissedCleavagePeptides.sql b/resources/queries/targetedms/UngroupedMissedCleavagePeptides.sql
new file mode 100644
index 000000000..c0a2cfbab
--- /dev/null
+++ b/resources/queries/targetedms/UngroupedMissedCleavagePeptides.sql
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
+ */
+-- Missed-cleavage peptides whose Attribute Group ID doesn't match any shorter peptide they contain, so PTM reports split the site across rows
+SELECT
+ l.RunId,
+ l.ProteinId,
+ l.Protein,
+ l.Sequence AS MissedCleavageSequence,
+ l.StartIndex,
+ l.EndIndex,
+ l.NumMissedCleavages,
+ l.GroupKey AS CurrentGroup,
+ GROUP_CONCAT(s.Sequence, ', ') AS ContainedPeptides
+FROM (
+ SELECT DISTINCT p.PeptideGroupId.RunId AS RunId, p.PeptideGroupId AS ProteinId, p.PeptideGroupId.Label AS Protein,
+ p.Sequence, p.StartIndex, p.EndIndex, p.NumMissedCleavages,
+ -- Skyline groups by unmodified sequence unless Attribute Group ID is set
+ COALESCE(NULLIF(p.AttributeGroupId, ''), p.Sequence) AS GroupKey
+ FROM Peptide p
+ WHERE p.Decoy IS NOT TRUE
+ AND p.NumMissedCleavages > 0
+ AND p.PeptideGroupId.RunId.ReplacedByRun IS NULL
+) l
+INNER JOIN (
+ SELECT DISTINCT p.PeptideGroupId AS ProteinId, p.Sequence, p.StartIndex, p.EndIndex, p.NumMissedCleavages,
+ COALESCE(NULLIF(p.AttributeGroupId, ''), p.Sequence) AS GroupKey
+ FROM Peptide p
+ WHERE p.Decoy IS NOT TRUE
+) s
+ ON s.ProteinId = l.ProteinId
+ AND s.Sequence <> l.Sequence
+ AND s.StartIndex >= l.StartIndex
+ AND s.EndIndex <= l.EndIndex
+ AND s.NumMissedCleavages < l.NumMissedCleavages
+GROUP BY l.RunId, l.ProteinId, l.Protein, l.Sequence, l.StartIndex, l.EndIndex, l.NumMissedCleavages, l.GroupKey
+-- A missed-cleavage peptide spans several cleaved peptides but can only join one group
+HAVING SUM(CASE WHEN s.GroupKey = l.GroupKey THEN 1 ELSE 0 END) = 0
diff --git a/src/org/labkey/targetedms/TargetedMSController.java b/src/org/labkey/targetedms/TargetedMSController.java
index b6ce357ec..0727be57e 100644
--- a/src/org/labkey/targetedms/TargetedMSController.java
+++ b/src/org/labkey/targetedms/TargetedMSController.java
@@ -249,6 +249,7 @@
import org.labkey.targetedms.query.SampleFileTable;
import org.labkey.targetedms.query.SkylineListManager;
import org.labkey.targetedms.query.SkylineListSchema;
+import org.labkey.targetedms.query.TargetedMSQueryView;
import org.labkey.targetedms.query.TargetedMSTable;
import org.labkey.targetedms.query.TransitionManager;
import org.labkey.targetedms.search.ModificationSearchWebPart;
@@ -5353,7 +5354,12 @@ protected QueryView createQueryView(RunDetailsForm form, BindException errors, b
{
QuerySettings settings = new QuerySettings(getViewContext(), _dataRegionName, "PeptideIds");
settings.setBaseFilter(new SimpleFilter(FieldKey.fromParts("PeptideGroupId", "RunId"), form.getId()));
- return createViewWithNoContainerFilterOptions(settings, errors);
+ QueryView result = createViewWithNoContainerFilterOptions(settings, errors);
+ if (result instanceof TargetedMSQueryView view)
+ {
+ view.setSplitCrossLinkedPeptides(true);
+ }
+ return result;
}
}
diff --git a/src/org/labkey/targetedms/query/CDRConditionalFormattingDisplayColumnFactory.java b/src/org/labkey/targetedms/query/CDRConditionalFormattingDisplayColumnFactory.java
index 4d2eaa6ae..3d5535d91 100644
--- a/src/org/labkey/targetedms/query/CDRConditionalFormattingDisplayColumnFactory.java
+++ b/src/org/labkey/targetedms/query/CDRConditionalFormattingDisplayColumnFactory.java
@@ -39,22 +39,25 @@ class CDRConditionalFormattingDisplayColumnFactory implements DisplayColumnFacto
private final Function _proteinGetter;
private final Map, Pair> _stressedSamples;
private final long _runId;
+ private final Set _terminalModifications;
- public CDRConditionalFormattingDisplayColumnFactory(Function proteinGetter, Map, Pair> stressedSamples, long runId)
+ public CDRConditionalFormattingDisplayColumnFactory(Function proteinGetter, Map, Pair> stressedSamples, long runId, Set terminalModifications)
{
_proteinGetter = proteinGetter;
_stressedSamples = stressedSamples;
_runId = runId;
+ _terminalModifications = terminalModifications;
}
- public static RiskLevel getRiskLevel(Number value, boolean inCDR, boolean stressed, String modification, String modifiedSequence)
+ public static RiskLevel getRiskLevel(Number value, boolean inCDR, boolean stressed, boolean terminalModification, String modifiedSequence)
{
if (value == null)
{
return null;
}
- if ("Gln->pyro-Glu (N-term Q)".equalsIgnoreCase(modification))
+ // Issue 55036: N- and C-terminal modifications aren't risk-scored
+ if (terminalModification)
{
return null;
}
@@ -120,7 +123,7 @@ public static boolean isInCDR(FieldKey parentFieldKey, RenderContext ctx, Functi
@Override
public DisplayColumn createRenderer(ColumnInfo boundCol)
{
- return new Col(boundCol, _proteinGetter, _stressedSamples, _runId);
+ return new Col(boundCol, _proteinGetter, _stressedSamples, _runId, _terminalModifications);
}
public enum RiskLevel
@@ -150,13 +153,15 @@ private static class Col extends DataColumn
private final Function _proteinGetter;
private final Map, Pair> _stressedSamples;
private final long _runId;
+ private final Set _terminalModifications;
- public Col(ColumnInfo colInfo, Function proteinGetter, Map, Pair> stressedSamples, long runId)
+ public Col(ColumnInfo colInfo, Function proteinGetter, Map, Pair> stressedSamples, long runId, Set terminalModifications)
{
super(colInfo);
_proteinGetter = proteinGetter;
_stressedSamples = stressedSamples;
_runId = runId;
+ _terminalModifications = terminalModifications;
}
@Override
@@ -173,7 +178,7 @@ public void addQueryFieldKeys(Set keys)
@Nullable
private ConditionalFormat createConditionalFormat(Number value, boolean inCDR, boolean stressed, String modification, String modifiedSequence)
{
- RiskLevel level = getRiskLevel(value, inCDR, stressed, modification, modifiedSequence);
+ RiskLevel level = getRiskLevel(value, inCDR, stressed, modification != null && _terminalModifications.contains(modification), modifiedSequence);
if (level == null)
{
return null;
diff --git a/src/org/labkey/targetedms/query/CrossLinkedPeptideDisplayColumn.java b/src/org/labkey/targetedms/query/CrossLinkedPeptideDisplayColumn.java
index bdd0b67d3..46b103bb7 100644
--- a/src/org/labkey/targetedms/query/CrossLinkedPeptideDisplayColumn.java
+++ b/src/org/labkey/targetedms/query/CrossLinkedPeptideDisplayColumn.java
@@ -17,6 +17,7 @@
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.junit.Assert;
import org.junit.Test;
import org.labkey.api.collections.LongHashMap;
@@ -34,6 +35,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
/**
* Renders the cross-linked peptide info in a few different variants. For unlinked peptides, passes through the primary
@@ -87,34 +89,41 @@ public Object getValue(RenderContext ctx)
CrossLinkedPeptideInfo i = new CrossLinkedPeptideInfo(modifiedSequence);
List proteins = getProteinsFromRun(ctx);
List sequences = i.getAllSequences();
- return render(sequences, proteins, modification);
+ Integer peptideIndex = ctx.get(CrossLinkedPeptideExcelWriter.PEPTIDE_INDEX) instanceof Integer index ? index : null;
+ return render(sequences, proteins, modification, peptideIndex);
}
return defaultValue;
}
- protected @NotNull String render(List sequences, List proteins, String modification)
+ /** @param peptideIndex non-null when the Excel export is writing each cross-linked peptide on its own row */
+ protected @NotNull String render(List sequences, List proteins, String modification, @Nullable Integer peptideIndex)
{
- StringBuilder result = new StringBuilder();
- String outerSeparator = "";
+ return formatAll(sequences, proteins, modification).stream()
+ .filter(value -> !value.isEmpty())
+ .collect(Collectors.joining("\n"));
+ }
- for (CrossLinkedPeptideInfo.PeptideSequence sequence : sequences)
- {
- List matches = sequence.findMatches(proteins);
- if (!matches.isEmpty())
- {
- result.append(outerSeparator);
- outerSeparator = "\n";
+ /** @return the formatted value for each peptide, blank when it matches no protein */
+ protected @NotNull List formatAll(List sequences, List proteins, String modification)
+ {
+ return sequences.stream().map(sequence -> formatSequence(sequence, proteins, modification)).toList();
+ }
- String innerSeparator = "";
- for (CrossLinkedPeptideInfo.Match match : matches)
- {
- result.append(innerSeparator);
- innerSeparator = "; ";
- result.append(_formatter.format(match, sequence, modification));
- }
- }
+ protected @NotNull List getPeptideValues(RenderContext ctx)
+ {
+ String modifiedSequence = ctx.get(getModifiedSequenceFieldKey(), String.class);
+ if (modifiedSequence == null)
+ {
+ return List.of();
}
- return result.toString();
+ return formatAll(new CrossLinkedPeptideInfo(modifiedSequence).getAllSequences(), getProteinsFromRun(ctx), ctx.get(getModificationFieldKey(), String.class));
+ }
+
+ protected @NotNull String formatSequence(CrossLinkedPeptideInfo.PeptideSequence sequence, List proteins, String modification)
+ {
+ return sequence.findMatches(proteins).stream()
+ .map(match -> _formatter.format(match, sequence, modification))
+ .collect(Collectors.joining("; "));
}
private List getProteinsFromRun(RenderContext ctx)
@@ -127,7 +136,7 @@ private List getProteinsFromRun(RenderContext ctx)
return Collections.emptyList();
}
- private FieldKey getModifiedSequenceFieldKey()
+ protected FieldKey getModifiedSequenceFieldKey()
{
return FieldKey.fromString(getColumnInfo().getFieldKey().getParent(), "PeptideModifiedSequence");
}
@@ -151,12 +160,69 @@ public void addQueryFieldKeys(Set keys)
keys.add(getRunIdFieldKey());
}
+ /** Renders one value per cross-linked peptide, so the Excel export puts each on its own row */
+ private static class PerPeptideColumn extends CrossLinkedPeptideDisplayColumn implements CrossLinkedPeptideExcelWriter.PerPeptideColumn
+ {
+ private PerPeptideColumn(ColumnInfo col, Formatter formatter)
+ {
+ super(col, formatter);
+ }
+
+ @Override
+ public int getPeptideCount(RenderContext ctx)
+ {
+ String modifiedSequence = ctx.get(getModifiedSequenceFieldKey(), String.class);
+ return modifiedSequence == null ? 1 : new CrossLinkedPeptideInfo(modifiedSequence).getAllSequences().size();
+ }
+
+ @Override
+ protected @NotNull String render(List sequences, List proteins, String modification, @Nullable Integer peptideIndex)
+ {
+ if (peptideIndex == null)
+ {
+ return super.render(sequences, proteins, modification, null);
+ }
+ return peptideIndex < sequences.size() ? formatSequence(sequences.get(peptideIndex), proteins, modification) : "";
+ }
+ }
+
+ /** Splits across the Excel rows only when the cross-linked peptides are on different chains */
+ private static class ChainColumn extends PerPeptideColumn
+ {
+ private ChainColumn(ColumnInfo col)
+ {
+ super(col, (match, _, _) -> match.protein().getLabel() == null ? match.protein().getName() : match.protein().getLabel());
+ }
+
+ @Override
+ public boolean isSplit(RenderContext ctx)
+ {
+ return getDistinctChains(getPeptideValues(ctx)).size() > 1;
+ }
+
+ @Override
+ protected @NotNull String render(List sequences, List proteins, String modification, @Nullable Integer peptideIndex)
+ {
+ List chains = getDistinctChains(formatAll(sequences, proteins, modification));
+ if (peptideIndex == null || chains.size() > 1)
+ {
+ return super.render(sequences, proteins, modification, peptideIndex);
+ }
+ return String.join("\n", chains);
+ }
+
+ private static List getDistinctChains(List values)
+ {
+ return values.stream().filter(value -> !value.isEmpty()).distinct().toList();
+ }
+ }
+
public static class ChainFactory implements DisplayColumnFactory
{
@Override
public DisplayColumn createRenderer(ColumnInfo colInfo)
{
- return new CrossLinkedPeptideDisplayColumn(colInfo, (match, sequence, modification) -> match.protein().getLabel() == null ? match.protein().getName() : match.protein().getLabel());
+ return new ChainColumn(colInfo);
}
}
@@ -165,7 +231,7 @@ public static class PeptideLocationFactory implements DisplayColumnFactory
@Override
public DisplayColumn createRenderer(ColumnInfo colInfo)
{
- return new CrossLinkedPeptideDisplayColumn(colInfo, (match, sequence, modification) ->
+ return new PerPeptideColumn(colInfo, (match, sequence, modification) ->
(match.index() + 1) + "-" + (match.index() + getPeptideLength(sequence.getUnmodified(), modification)));
}
}
@@ -185,7 +251,7 @@ public static class PeptideIdentityFactory implements DisplayColumnFactory
@Override
public DisplayColumn createRenderer(ColumnInfo colInfo)
{
- return new CrossLinkedPeptideDisplayColumn(colInfo, (match, sequence, modification) ->
+ return new PerPeptideColumn(colInfo, (match, sequence, modification) ->
{
String label = match.protein().getLabel();
String prefix = getChainPrefix(label);
@@ -224,7 +290,7 @@ public DisplayColumn createRenderer(ColumnInfo colInfo)
return new CrossLinkedPeptideDisplayColumn(colInfo, null)
{
@Override
- protected @NotNull String render(List sequences, List proteins, String modification)
+ protected @NotNull String render(List sequences, List proteins, String modification, @Nullable Integer peptideIndex)
{
List> allBonds = new ArrayList<>();
diff --git a/src/org/labkey/targetedms/query/CrossLinkedPeptideExcelWriter.java b/src/org/labkey/targetedms/query/CrossLinkedPeptideExcelWriter.java
new file mode 100644
index 000000000..848292e26
--- /dev/null
+++ b/src/org/labkey/targetedms/query/CrossLinkedPeptideExcelWriter.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.labkey.targetedms.query;
+
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.VerticalAlignment;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.ss.util.CellUtil;
+import org.labkey.api.data.DisplayColumn;
+import org.labkey.api.data.ExcelColumn;
+import org.labkey.api.data.ExcelWriter;
+import org.labkey.api.data.RenderContext;
+import org.labkey.api.data.ResultsFactory;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Writes each peptide of a cross-linked peptide on its own sheet row. {@link PerPeptideColumn}s that split the current
+ * row get one value per sheet row; all other columns are written once and merged across the rows.
+ */
+public class CrossLinkedPeptideExcelWriter extends ExcelWriter
+{
+ /** RenderContext key holding the 0-based index of the peptide being written, present only while splitting a row */
+ public static final String PEPTIDE_INDEX = "~~crosslinked-peptide-index~~";
+
+ /** A display column whose value has one entry per peptide in a cross-linked peptide */
+ public interface PerPeptideColumn
+ {
+ int getPeptideCount(RenderContext ctx);
+
+ /** @return false to write the current row's value once, merged across the peptides' rows */
+ default boolean isSplit(RenderContext ctx)
+ {
+ return true;
+ }
+ }
+
+ /** Longest line of text written to each column, in characters */
+ private int[] _maxLineLengths;
+
+ public CrossLinkedPeptideExcelWriter(ResultsFactory factory, List displayColumns, ExcelDocumentType docType, Map renameColumnMap)
+ {
+ super(factory, displayColumns, docType, renameColumnMap);
+ }
+
+ @Override
+ protected void renderGridRow(Sheet sheet, RenderContext ctx, List columns) throws MaxRowsExceededException
+ {
+ int firstRow = getCurrentRow();
+ try
+ {
+ renderPeptideRows(sheet, ctx, columns);
+ }
+ finally
+ {
+ // Measure now because the streaming workbook flushes rows to disk before adjustColumnWidths() runs
+ trackLineLengths(sheet, columns, firstRow, getCurrentRow() - 1);
+ }
+ }
+
+ private void trackLineLengths(Sheet sheet, List columns, int firstRow, int lastRow)
+ {
+ if (_maxLineLengths == null)
+ {
+ _maxLineLengths = new int[columns.size()];
+ }
+ for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++)
+ {
+ Row row = sheet.getRow(rowIndex);
+ for (int column = 0; row != null && column < _maxLineLengths.length; column++)
+ {
+ Cell cell = row.getCell(column);
+ if (cell != null && cell.getCellType() == CellType.STRING)
+ {
+ for (String line : cell.getStringCellValue().split("\n"))
+ {
+ _maxLineLengths[column] = Math.max(_maxLineLengths[column], line.length());
+ }
+ }
+ }
+ }
+ }
+
+ /** POI's auto-sizing skips merged cells and measures with the server's fonts, which run narrower than Excel's */
+ @Override
+ public void adjustColumnWidths(RenderContext ctx, Sheet sheet, List visibleColumns)
+ {
+ super.adjustColumnWidths(ctx, sheet, visibleColumns);
+ for (int column = 0; _maxLineLengths != null && column < _maxLineLengths.length; column++)
+ {
+ // Pad for capitals, which Excel renders wider than its width unit, the digit '0'
+ int width = Math.min(_maxLineLengths[column] + 2, 255) * 256;
+ if (_maxLineLengths[column] > 0 && width > sheet.getColumnWidth(column))
+ {
+ sheet.setColumnWidth(column, width);
+ }
+ }
+ }
+
+ private void renderPeptideRows(Sheet sheet, RenderContext ctx, List columns) throws MaxRowsExceededException
+ {
+ boolean[] split = new boolean[columns.size()];
+ int peptideCount = 1;
+ for (int column = 0; column < columns.size(); column++)
+ {
+ if (columns.get(column).getDisplayColumn() instanceof PerPeptideColumn perPeptide && perPeptide.isSplit(ctx))
+ {
+ split[column] = true;
+ peptideCount = Math.max(peptideCount, perPeptide.getPeptideCount(ctx));
+ }
+ }
+
+ if (peptideCount <= 1)
+ {
+ super.renderGridRow(sheet, ctx, columns);
+ return;
+ }
+
+ int firstRow = getCurrentRow();
+ try
+ {
+ ctx.put(PEPTIDE_INDEX, 0);
+ super.renderGridRow(sheet, ctx, columns);
+
+ for (int peptideIndex = 1; peptideIndex < peptideCount; peptideIndex++)
+ {
+ ctx.put(PEPTIDE_INDEX, peptideIndex);
+ for (int column = 0; column < columns.size(); column++)
+ {
+ if (split[column])
+ {
+ columns.get(column).writeCell(sheet, column, getCurrentRow(), ctx);
+ }
+ }
+ incrementRow();
+ }
+ }
+ finally
+ {
+ ctx.remove(PEPTIDE_INDEX);
+ // Also merge the rows already written when MaxRowsExceededException cuts the cross-link short
+ mergeSharedColumns(sheet, split, firstRow, getCurrentRow() - 1);
+ }
+ }
+
+ private void mergeSharedColumns(Sheet sheet, boolean[] split, int firstRow, int lastRow)
+ {
+ if (lastRow <= firstRow)
+ {
+ return;
+ }
+ for (int column = 0; column < split.length; column++)
+ {
+ if (!split[column])
+ {
+ // Regions are disjoint by construction; the validating addMergedRegion is quadratic in the region count
+ sheet.addMergedRegionUnsafe(new CellRangeAddress(firstRow, lastRow, column, column));
+ CellUtil.setVerticalAlignment(sheet.getRow(firstRow).getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK), VerticalAlignment.CENTER);
+ }
+ }
+ }
+}
diff --git a/src/org/labkey/targetedms/query/ModificationManager.java b/src/org/labkey/targetedms/query/ModificationManager.java
index 392dd06cc..c842394a7 100644
--- a/src/org/labkey/targetedms/query/ModificationManager.java
+++ b/src/org/labkey/targetedms/query/ModificationManager.java
@@ -17,6 +17,7 @@
import org.labkey.api.cache.Cache;
import org.labkey.api.cache.CacheManager;
+import org.labkey.api.collections.CaseInsensitiveHashSet;
import org.labkey.api.collections.IntHashMap;
import org.labkey.api.collections.LongHashMap;
import org.labkey.api.data.SQLFragment;
@@ -175,6 +176,22 @@ public static List getStructuralModif
return new SqlSelector(TargetedMSManager.getSchema(), sql).getArrayList(PeptideSettings.RunStructuralModification.class);
}
+ /** Names of the run's N- or C-terminal structural modifications, as reported by PTMPercentsGroupedPrepivot */
+ public static Set getTerminalStructuralModificationNames(long runId)
+ {
+ Set result = new CaseInsensitiveHashSet();
+ getStructuralModificationsForRun(runId).stream()
+ .filter(mod -> mod.getTerminus() != null && !mod.getTerminus().isBlank())
+ .map(PeptideSettings.Modification::getName)
+ .forEach(result::add);
+ // PTMPercentsGroupedPrepivot.sql renames this modification
+ if (result.contains("Lys-loss (Protein C-term K)"))
+ {
+ result.add("C-term K");
+ }
+ return result;
+ }
+
/**
* Returns a list of isotopic modifications found in at least one peptide in the run (Skyline document).
* @return List of isotopic modifications
diff --git a/src/org/labkey/targetedms/query/ModifiedSequenceDisplayColumn.java b/src/org/labkey/targetedms/query/ModifiedSequenceDisplayColumn.java
index 4f9203222..fb346c9e9 100644
--- a/src/org/labkey/targetedms/query/ModifiedSequenceDisplayColumn.java
+++ b/src/org/labkey/targetedms/query/ModifiedSequenceDisplayColumn.java
@@ -50,7 +50,7 @@
/**
* User: vsharma
*/
-public abstract class ModifiedSequenceDisplayColumn extends IconColumn
+public abstract class ModifiedSequenceDisplayColumn extends IconColumn implements CrossLinkedPeptideExcelWriter.PerPeptideColumn
{
private static final Logger LOG = LogHelper.getLogger(ModifiedSequenceDisplayColumn.class, "Wires up formatting for peptide sequences");
private final ModifiedPeptideHtmlMaker _htmlMaker;
@@ -128,6 +128,21 @@ private String extractTextFromHtml(HtmlString html)
return html.toString().replaceAll("<[^>]*>", "");
}
+ @Override
+ public int getPeptideCount(RenderContext ctx)
+ {
+ initialize(ctx);
+ return _parsedParts == null ? 1 : Math.max(1, _parsedParts.size());
+ }
+
+ /** Unparsed values can't be split by peptide, so merge them instead */
+ @Override
+ public boolean isSplit(RenderContext ctx)
+ {
+ initialize(ctx);
+ return _parsedParts != null;
+ }
+
@Override
String getIconPath()
{
@@ -283,6 +298,7 @@ public void initialize(RenderContext ctx)
if (peptideId == null || sequence == null || runId == null)
{
_cellData = HtmlString.of(peptideModifiedSequence);
+ _parsedParts = null;
}
else
{
@@ -381,6 +397,7 @@ public void initialize(RenderContext ctx)
if (precursorId == null || peptideId == null || isotopeLabelId == null || precursorModifiedSequence == null || sequence == null || runId == null)
{
_cellData = HtmlString.of(precursorModifiedSequence);
+ _parsedParts = null;
}
else
{
@@ -417,12 +434,23 @@ private class ExcelColumnImpl extends ExcelColumn
public void writeCell(Sheet sheet, int column, int row, RenderContext ctx)
{
ModifiedSequenceDisplayColumn.this.initialize(ctx);
+ if (_parsedParts == null)
+ {
+ super.writeCell(sheet, column, row, ctx);
+ return;
+ }
+
+ List> peptides = _parsedParts;
+ if (ctx.get(CrossLinkedPeptideExcelWriter.PEPTIDE_INDEX) instanceof Integer peptideIndex)
+ {
+ peptides = peptideIndex < _parsedParts.size() ? List.of(_parsedParts.get(peptideIndex)) : List.of();
+ }
StringBuilder builder = new StringBuilder();
Map highlights = new IntHashMap<>();
int i = 0;
String separator = "";
- for (List parts : _parsedParts)
+ for (List parts : peptides)
{
i += separator.length();
builder.append(separator);
@@ -469,7 +497,7 @@ else if (part.modified())
richText.applyFont(entry.getKey(), entry.getKey() + 1, entry.getValue());
}
- if (_parsedParts.size() > 1)
+ if (peptides.size() > 1)
{
// We have multiple cross-linked peptides so we need a taller row to show them on separate lines within
// the same cell
@@ -479,7 +507,7 @@ else if (part.modified())
_wrappedStyle.setWrapText(true);
}
cell.setCellStyle(_wrappedStyle);
- float minHeight = sheet.getDefaultRowHeightInPoints() * _parsedParts.size();
+ float minHeight = sheet.getDefaultRowHeightInPoints() * peptides.size();
if (rowObject.getHeightInPoints() < minHeight)
{
rowObject.setHeightInPoints(minHeight);
diff --git a/src/org/labkey/targetedms/query/PTMPercentsGroupedCustomizer.java b/src/org/labkey/targetedms/query/PTMPercentsGroupedCustomizer.java
index 99127458a..8eb4ed067 100644
--- a/src/org/labkey/targetedms/query/PTMPercentsGroupedCustomizer.java
+++ b/src/org/labkey/targetedms/query/PTMPercentsGroupedCustomizer.java
@@ -114,6 +114,7 @@ public void customize(TableInfo tableInfo)
Optional opt = proteins.stream().filter(p -> p.getPeptideGroupId() == peptideGroupId.intValue()).findFirst();
return opt.orElse(null);
};
+ Set terminalModifications = ModificationManager.getTerminalStructuralModificationNames(runId);
for (ColumnInfo c : tableInfo.getColumns())
{
@@ -122,7 +123,7 @@ public void customize(TableInfo tableInfo)
if (col.getName().endsWith("::TotalPercentModified") || col.getName().endsWith("::PercentModified"))
{
col.setFormat("0.0%");
- col.setDisplayColumnFactory(new CDRConditionalFormattingDisplayColumnFactory(proteinGetter, stressedSamples, runId));
+ col.setDisplayColumnFactory(new CDRConditionalFormattingDisplayColumnFactory(proteinGetter, stressedSamples, runId, terminalModifications));
}
if (col.getName().endsWith("::TotalPercentModified") ||
diff --git a/src/org/labkey/targetedms/query/PTMRiskDisplayColumnFactory.java b/src/org/labkey/targetedms/query/PTMRiskDisplayColumnFactory.java
index fac9bf042..c8c766238 100644
--- a/src/org/labkey/targetedms/query/PTMRiskDisplayColumnFactory.java
+++ b/src/org/labkey/targetedms/query/PTMRiskDisplayColumnFactory.java
@@ -15,6 +15,7 @@
*/
package org.labkey.targetedms.query;
+import org.labkey.api.collections.LongHashMap;
import org.labkey.api.data.ColumnInfo;
import org.labkey.api.data.DataColumn;
import org.labkey.api.data.DisplayColumnFactory;
@@ -39,6 +40,7 @@ private static class Col extends DataColumn
{
private final Function _proteinGetter = new PTMPercentsGroupedCustomizer.PeptideGroupIdProteinGetter();
private Map, Pair> _stressedSamples;
+ private final Map> _terminalModifications = new LongHashMap<>();
public Col(ColumnInfo col)
{
@@ -82,7 +84,9 @@ public Object getValue(RenderContext ctx)
Pair metadata = _stressedSamples.get(Pair.of(replicateName, runId));
boolean stressed = metadata != null && metadata.first.booleanValue();
- result = CDRConditionalFormattingDisplayColumnFactory.getRiskLevel(percentModified, cdr, stressed, modification, modifiedSequence);
+ boolean terminal = modification != null && _terminalModifications.computeIfAbsent(runId, ModificationManager::getTerminalStructuralModificationNames).contains(modification);
+
+ result = CDRConditionalFormattingDisplayColumnFactory.getRiskLevel(percentModified, cdr, stressed, terminal, modifiedSequence);
}
return result;
}
diff --git a/src/org/labkey/targetedms/query/TargetedMSQueryView.java b/src/org/labkey/targetedms/query/TargetedMSQueryView.java
index 8396b095f..5c3070588 100644
--- a/src/org/labkey/targetedms/query/TargetedMSQueryView.java
+++ b/src/org/labkey/targetedms/query/TargetedMSQueryView.java
@@ -17,9 +17,12 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import org.labkey.api.data.DataRegion;
import org.labkey.api.data.ExcelWriter;
+import org.labkey.api.data.RenderContext;
import org.labkey.api.query.QuerySettings;
import org.labkey.api.query.QueryView;
+import org.labkey.api.view.DataView;
import org.labkey.targetedms.TargetedMSSchema;
import org.springframework.validation.BindException;
@@ -28,6 +31,8 @@
public class TargetedMSQueryView extends QueryView
{
+ private boolean _splitCrossLinkedPeptides = false;
+
public TargetedMSQueryView(TargetedMSSchema schema, @NotNull QuerySettings settings, BindException errors)
{
super(schema, settings, errors);
@@ -43,6 +48,23 @@ public ExcelWriter getExcelWriter(ExcelWriter.ExcelDocumentType docType, @Nullab
{
docType = ExcelWriter.ExcelDocumentType.xlsxSharedStrings;
}
- return super.getExcelWriter(docType, renameColumnMap);
+ if (!_splitCrossLinkedPeptides)
+ {
+ return super.getExcelWriter(docType, renameColumnMap);
+ }
+
+ DataView view = createDataView();
+ DataRegion rgn = view.getDataRegion();
+ RenderContext rc = configureForExcelExport(docType, view, rgn);
+ ExcelWriter ew = new CrossLinkedPeptideExcelWriter(() -> rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), docType, renameColumnMap);
+ ew.setFilenamePrefix(getSettings().getQueryName());
+ ew.setAutoSize(true);
+ return ew;
+ }
+
+ /** Excel export writes each peptide of a cross-linked peptide on its own row */
+ public void setSplitCrossLinkedPeptides(boolean splitCrossLinkedPeptides)
+ {
+ _splitCrossLinkedPeptides = splitCrossLinkedPeptides;
}
}
diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSEarlyStagePTMReportTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSEarlyStagePTMReportTest.java
index d38151b41..a6f237ca1 100644
--- a/test/src/org/labkey/test/tests/targetedms/TargetedMSEarlyStagePTMReportTest.java
+++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSEarlyStagePTMReportTest.java
@@ -32,8 +32,10 @@
import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import java.util.stream.IntStream;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
@@ -109,6 +111,14 @@ private void verifyPrepivotData(DataRegionTable table)
table = new DataRegionTable("query", this);
assertEquals(List.of("false", "false"), table.getColumnDataAsText("IsCdr"));
assertEquals(List.of(" ", " "), table.getColumnDataAsText("Risk"));
+
+ // C-terminal modification without a special-cased name: Lys clipping
+ table.clearFilter("PeptideModifiedSequence");
+ table.setFilter("PeptideModifiedSequence", "Contains", "SLSLSPGK");
+ table = new DataRegionTable("query", this);
+ List risks = table.getColumnDataAsText("Risk");
+ assertFalse("Expected rows for SLSLSPGK", risks.isEmpty());
+ assertTrue("Terminal modification should not be risk-scored: " + risks, risks.stream().allMatch(String::isBlank));
}
@Test
@@ -143,10 +153,14 @@ public void testCachePopulationUpgradeCode() throws Exception
}
/** Chrome may report opaque colors as rgba(r, g, b, 1) */
+ private String getBackgroundColor(WebElement cell)
+ {
+ return cell.getCssValue("background-color").replaceFirst("^rgba\\((.*), 1\\)$", "rgb($1)");
+ }
+
private void assertColor(String message, String expectedRgb, WebElement cell)
{
- String actual = cell.getCssValue("background-color");
- assertEquals(message, expectedRgb, actual.replaceFirst("^rgba\\((.*), 1\\)$", "rgb($1)"));
+ assertEquals(message, expectedRgb, getBackgroundColor(cell));
}
private void invokeUpgradeCode(String method)
@@ -208,7 +222,7 @@ private void verifyEarlyStagePTMReport()
assertEquals("Incorrect Sample Names displayed as headers", "Sample1 QE_2",
Locator.xpath("//table/thead[2]/tr").findElement(reportTable).getText());
- int qvtRowIndex = 0; // Special-cased modification: Gln->pyro-Glu (N-term Q)
+ int qvtRowIndex = 0; // N-terminal modification, so not risk-colored: Gln->pyro-Glu (N-term Q)
int vtnRowIndex = 1;
int eeqRowIndex = 3; // Special-cased peptide: EEQYNSTYR(V)
int wqqRowIndex = 7;
@@ -232,5 +246,13 @@ private void verifyEarlyStagePTMReport()
Locator.xpath("//table/tbody/tr[" + (eeqRowIndex + 1) + "]/td[6]").findElement(reportTable));
assertColor("Incorrect risk category color for WQQ/Sample1 - Green", "rgb(137, 202, 83)",
Locator.xpath("//table/tbody/tr[" + (wqqRowIndex + 1) + "]/td[6]").findElement(reportTable));
+
+ log("Verifying a C-terminal modification isn't risk-colored");
+ List sequences = reportTable.getColumnDataAsText("Sequence");
+ int slsRowIndex = IntStream.range(0, sequences.size()).filter(i -> sequences.get(i).contains("SLSLSPGK")).findFirst()
+ .orElseThrow(() -> new AssertionError("SLSLSPGK not found in report: " + sequences));
+ String slsColor = getBackgroundColor(Locator.xpath("//table/tbody/tr[" + (slsRowIndex + 1) + "]/td[6]").findElement(reportTable));
+ assertFalse("C-terminal Lys clipping should not be risk-colored: " + slsColor,
+ List.of("rgb(137, 202, 83)", "rgb(254, 255, 63)", "rgb(250, 8, 26)").contains(slsColor));
}
}
diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java
index e079278e4..c9ddde34c 100644
--- a/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java
+++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java
@@ -15,12 +15,22 @@
*/
package org.labkey.test.tests.targetedms;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.util.CellRangeAddress;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.experimental.categories.Category;
import org.labkey.test.Locator;
+import org.labkey.test.util.DataRegionExportHelper;
import org.labkey.test.util.DataRegionTable;
+import org.labkey.test.util.ExcelHelper;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
@@ -93,4 +103,43 @@ public void testCrossLinkedPeptideMap()
// Ensure that the highlighting is as expected for both crosslinking and modification
assertTrue(getHtmlSource().contains("(R)VSSQ(Q)"));
}
+
+ @Test
+ public void testCrossLinkedPeptideMapExcelExport() throws IOException
+ {
+ goToProjectHome();
+
+ clickAndWait(Locator.linkContainingText("Panorama Dashboard"));
+ clickAndWait(Locator.linkContainingText(CROSS_LINKED_SKY_FILE));
+ clickAndWait(Locator.linkContainingText("Peptide Map"));
+
+ DataRegionTable table = new DataRegionTable("PeptideIds", getDriver());
+ File export = new DataRegionExportHelper(table).exportExcel(DataRegionExportHelper.ExcelFileType.XLSX);
+ try (Workbook workbook = ExcelHelper.create(export))
+ {
+ Sheet sheet = workbook.getSheetAt(0);
+ List captions = ExcelHelper.getRowData(sheet, 0);
+ int retentionTime = captions.indexOf("Retention Time");
+ int peptideIdentity = captions.indexOf("Peptide Identity");
+ int bondLocation = captions.indexOf("Disulfide Bond Connection");
+ int peptide = captions.indexOf("Peptide");
+
+ // Each of the two three-peptide cross-links spans three sheet rows
+ assertEquals("Sheet rows", 7, sheet.getLastRowNum() + 1);
+ assertEquals("Peptide Identity", List.of("121-124", "342-345", "142-145"), ExcelHelper.getColumnData(sheet, peptideIdentity).subList(1, 4));
+ assertEquals("Peptide", "(R)VSSQ(Q)", ExcelHelper.getCellContentsAt(sheet, peptide, 1));
+ assertEquals("Disulfide Bond Connection", "V121-S345-Q142/\nQ124-S345-Q142", ExcelHelper.getCellContentsAt(sheet, bondLocation, 1));
+
+ List merged = sheet.getMergedRegions();
+ for (int column : List.of(retentionTime, bondLocation))
+ {
+ assertTrue("Column " + captions.get(column) + " not merged for first cross-link", merged.contains(new CellRangeAddress(1, 3, column, column)));
+ assertTrue("Column " + captions.get(column) + " not merged for second cross-link", merged.contains(new CellRangeAddress(4, 6, column, column)));
+ }
+ for (int column : List.of(peptideIdentity, peptide))
+ {
+ assertTrue("Per-peptide column " + captions.get(column) + " should not be merged", merged.stream().noneMatch(r -> r.getFirstColumn() == column));
+ }
+ }
+ }
}