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)); + } + } + } }