JAL-3518 more pull up / test coverage of structure command generation
[jalview.git] / src / jalview / ext / rbvi / chimera / ChimeraCommands.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.ext.rbvi.chimera;
22
23 import jalview.api.AlignViewportI;
24 import jalview.api.AlignmentViewPanel;
25 import jalview.api.FeatureRenderer;
26 import jalview.datamodel.AlignmentI;
27 import jalview.datamodel.MappedFeatures;
28 import jalview.datamodel.SequenceFeature;
29 import jalview.datamodel.SequenceI;
30 import jalview.gui.Desktop;
31 import jalview.structure.AtomSpecModel;
32 import jalview.structure.StructureCommandsBase;
33 import jalview.structure.StructureMapping;
34 import jalview.structure.StructureSelectionManager;
35 import jalview.util.ColorUtils;
36 import jalview.util.IntRangeComparator;
37
38 import java.awt.Color;
39 import java.util.ArrayList;
40 import java.util.Collections;
41 import java.util.HashMap;
42 import java.util.Iterator;
43 import java.util.LinkedHashMap;
44 import java.util.List;
45 import java.util.Map;
46
47 /**
48  * Routines for generating Chimera commands for Jalview/Chimera binding
49  * 
50  * @author JimP
51  * 
52  */
53 public class ChimeraCommands extends StructureCommandsBase
54 {
55   public static final String NAMESPACE_PREFIX = "jv_";
56
57   private static final String CMD_COLOUR_BY_CHARGE = "color white;color red ::ASP;color red ::GLU;color blue ::LYS;color blue ::ARG;color yellow ::CYS";
58
59   private static final String CMD_COLOUR_BY_CHAIN = "rainbow chain";
60
61   // Chimera clause to exclude alternate locations in atom selection
62   private static final String NO_ALTLOCS = "&~@.B-Z&~@.2-9";
63
64   @Override
65   public String getColourCommand(String atomSpec, Color colour)
66   {
67     // https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/color.html
68     String colourCode = getColourString(colour);
69     return "color " + colourCode + " " + atomSpec;
70   }
71
72   /**
73    * Returns a colour formatted suitable for use in viewer command syntax
74    * 
75    * @param colour
76    * @return
77    */
78   protected String getColourString(Color colour)
79   {
80     return ColorUtils.toTkCode(colour);
81   }
82
83   /**
84    * Constructs and returns Chimera commands to set attributes on residues
85    * corresponding to features in Jalview. Attribute names are the Jalview feature
86    * type, with a "jv_" prefix.
87    * 
88    * @param ssm
89    * @param files
90    * @param seqs
91    * @param viewPanel
92    * @return
93    */
94   @Override
95   public String[] setAttributesForFeatures(
96           StructureSelectionManager ssm, String[] files, SequenceI[][] seqs,
97           AlignmentViewPanel viewPanel)
98   {
99     Map<String, Map<Object, AtomSpecModel>> featureMap = buildFeaturesMap(
100             ssm, files, seqs, viewPanel);
101
102     return setAttributes(featureMap);
103   }
104
105   /**
106    * <pre>
107    * Helper method to build a map of 
108    *   { featureType, { feature value, AtomSpecModel } }
109    * </pre>
110    * 
111    * @param ssm
112    * @param files
113    * @param seqs
114    * @param viewPanel
115    * @return
116    */
117   protected Map<String, Map<Object, AtomSpecModel>> buildFeaturesMap(
118           StructureSelectionManager ssm, String[] files, SequenceI[][] seqs,
119           AlignmentViewPanel viewPanel)
120   {
121     Map<String, Map<Object, AtomSpecModel>> theMap = new LinkedHashMap<>();
122
123     FeatureRenderer fr = viewPanel.getFeatureRenderer();
124     if (fr == null)
125     {
126       return theMap;
127     }
128
129     AlignViewportI viewport = viewPanel.getAlignViewport();
130     List<String> visibleFeatures = fr.getDisplayedFeatureTypes();
131
132     /*
133      * if alignment is showing features from complement, we also transfer
134      * these features to the corresponding mapped structure residues
135      */
136     boolean showLinkedFeatures = viewport.isShowComplementFeatures();
137     List<String> complementFeatures = new ArrayList<>();
138     FeatureRenderer complementRenderer = null;
139     if (showLinkedFeatures)
140     {
141       AlignViewportI comp = fr.getViewport().getCodingComplement();
142       if (comp != null)
143       {
144         complementRenderer = Desktop.getAlignFrameFor(comp)
145                 .getFeatureRenderer();
146         complementFeatures = complementRenderer.getDisplayedFeatureTypes();
147       }
148     }
149     if (visibleFeatures.isEmpty() && complementFeatures.isEmpty())
150     {
151       return theMap;
152     }
153
154     AlignmentI alignment = viewPanel.getAlignment();
155     for (int pdbfnum = 0; pdbfnum < files.length; pdbfnum++)
156     {
157       final int modelNumber = pdbfnum + getModelStartNo();
158       StructureMapping[] mapping = ssm.getMapping(files[pdbfnum]);
159
160       if (mapping == null || mapping.length < 1)
161       {
162         continue;
163       }
164
165       for (int seqNo = 0; seqNo < seqs[pdbfnum].length; seqNo++)
166       {
167         for (int m = 0; m < mapping.length; m++)
168         {
169           final SequenceI seq = seqs[pdbfnum][seqNo];
170           int sp = alignment.findIndex(seq);
171           StructureMapping structureMapping = mapping[m];
172           if (structureMapping.getSequence() == seq && sp > -1)
173           {
174             /*
175              * found a sequence with a mapping to a structure;
176              * now scan its features
177              */
178             if (!visibleFeatures.isEmpty())
179             {
180               scanSequenceFeatures(visibleFeatures, structureMapping, seq,
181                       theMap, modelNumber);
182             }
183             if (showLinkedFeatures)
184             {
185               scanComplementFeatures(complementRenderer, structureMapping,
186                       seq, theMap, modelNumber);
187             }
188           }
189         }
190       }
191     }
192     return theMap;
193   }
194
195   /**
196    * Scans visible features in mapped positions of the CDS/peptide complement, and
197    * adds any found to the map of attribute values/structure positions
198    * 
199    * @param complementRenderer
200    * @param structureMapping
201    * @param seq
202    * @param theMap
203    * @param modelNumber
204    */
205   protected static void scanComplementFeatures(
206           FeatureRenderer complementRenderer,
207           StructureMapping structureMapping, SequenceI seq,
208           Map<String, Map<Object, AtomSpecModel>> theMap, int modelNumber)
209   {
210     /*
211      * for each sequence residue mapped to a structure position...
212      */
213     for (int seqPos : structureMapping.getMapping().keySet())
214     {
215       /*
216        * find visible complementary features at mapped position(s)
217        */
218       MappedFeatures mf = complementRenderer
219               .findComplementFeaturesAtResidue(seq, seqPos);
220       if (mf != null)
221       {
222         for (SequenceFeature sf : mf.features)
223         {
224           String type = sf.getType();
225
226           /*
227            * Don't copy features which originated from Chimera
228            */
229           if (JalviewChimeraBinding.CHIMERA_FEATURE_GROUP
230                   .equals(sf.getFeatureGroup()))
231           {
232             continue;
233           }
234
235           /*
236            * record feature 'value' (score/description/type) as at the
237            * corresponding structure position
238            */
239           List<int[]> mappedRanges = structureMapping
240                   .getPDBResNumRanges(seqPos, seqPos);
241
242           if (!mappedRanges.isEmpty())
243           {
244             String value = sf.getDescription();
245             if (value == null || value.length() == 0)
246             {
247               value = type;
248             }
249             float score = sf.getScore();
250             if (score != 0f && !Float.isNaN(score))
251             {
252               value = Float.toString(score);
253             }
254             Map<Object, AtomSpecModel> featureValues = theMap.get(type);
255             if (featureValues == null)
256             {
257               featureValues = new HashMap<>();
258               theMap.put(type, featureValues);
259             }
260             for (int[] range : mappedRanges)
261             {
262               addAtomSpecRange(featureValues, value, modelNumber, range[0],
263                       range[1], structureMapping.getChain());
264             }
265           }
266         }
267       }
268     }
269   }
270
271   /**
272    * Inspect features on the sequence; for each feature that is visible, determine
273    * its mapped ranges in the structure (if any) according to the given mapping,
274    * and add them to the map.
275    * 
276    * @param visibleFeatures
277    * @param mapping
278    * @param seq
279    * @param theMap
280    * @param modelNumber
281    */
282   protected static void scanSequenceFeatures(List<String> visibleFeatures,
283           StructureMapping mapping, SequenceI seq,
284           Map<String, Map<Object, AtomSpecModel>> theMap, int modelNumber)
285   {
286     List<SequenceFeature> sfs = seq.getFeatures().getPositionalFeatures(
287             visibleFeatures.toArray(new String[visibleFeatures.size()]));
288     for (SequenceFeature sf : sfs)
289     {
290       String type = sf.getType();
291
292       /*
293        * Don't copy features which originated from Chimera
294        */
295       if (JalviewChimeraBinding.CHIMERA_FEATURE_GROUP
296               .equals(sf.getFeatureGroup()))
297       {
298         continue;
299       }
300
301       List<int[]> mappedRanges = mapping.getPDBResNumRanges(sf.getBegin(),
302               sf.getEnd());
303
304       if (!mappedRanges.isEmpty())
305       {
306         String value = sf.getDescription();
307         if (value == null || value.length() == 0)
308         {
309           value = type;
310         }
311         float score = sf.getScore();
312         if (score != 0f && !Float.isNaN(score))
313         {
314           value = Float.toString(score);
315         }
316         Map<Object, AtomSpecModel> featureValues = theMap.get(type);
317         if (featureValues == null)
318         {
319           featureValues = new HashMap<>();
320           theMap.put(type, featureValues);
321         }
322         for (int[] range : mappedRanges)
323         {
324           addAtomSpecRange(featureValues, value, modelNumber, range[0],
325                   range[1], mapping.getChain());
326         }
327       }
328     }
329   }
330
331   /**
332    * Traverse the map of features/values/models/chains/positions to construct a
333    * list of 'setattr' commands (one per distinct feature type and value).
334    * <p>
335    * The format of each command is
336    * 
337    * <pre>
338    * <blockquote> setattr r <featureName> " " #modelnumber:range.chain 
339    * e.g. setattr r jv_chain &lt;value&gt; #0:2.B,4.B,9-12.B|#1:1.A,2-6.A,...
340    * </blockquote>
341    * </pre>
342    * 
343    * @param featureMap
344    * @return
345    */
346   protected String[] setAttributes(
347           Map<String, Map<Object, AtomSpecModel>> featureMap)
348   {
349     List<String> commands = new ArrayList<>();
350     for (String featureType : featureMap.keySet())
351     {
352       String attributeName = makeAttributeName(featureType);
353
354       /*
355        * clear down existing attributes for this feature
356        */
357       // 'problem' - sets attribute to None on all residues - overkill?
358       // commands.add("~setattr r " + attributeName + " :*");
359
360       Map<Object, AtomSpecModel> values = featureMap.get(featureType);
361       for (Object value : values.keySet())
362       {
363         /*
364          * for each distinct value recorded for this feature type,
365          * add a command to set the attribute on the mapped residues
366          * Put values in single quotes, encoding any embedded single quotes
367          */
368         AtomSpecModel atomSpecModel = values.get(value);
369         String featureValue = value.toString();
370         featureValue = featureValue.replaceAll("\\'", "&#39;");
371         String cmd = setAttribute(attributeName, featureValue,
372                 atomSpecModel);
373         commands.add(cmd);
374       }
375     }
376
377     return commands.toArray(new String[commands.size()]);
378   }
379
380   /**
381    * Returns a viewer command to set the given residue attribute value on
382    * residues specified by the AtomSpecModel, for example
383    * 
384    * <pre>
385    * setatr res jv_chain 'primary' #1:12-34,48-55.B
386    * </pre>
387    * 
388    * @param attributeName
389    * @param attributeValue
390    * @param atomSpecModel
391    * @return
392    */
393   protected String setAttribute(String attributeName,
394           String attributeValue,
395           AtomSpecModel atomSpecModel)
396   {
397     StringBuilder sb = new StringBuilder(128);
398     sb.append("setattr res ").append(attributeName).append(" '")
399             .append(attributeValue).append("' ");
400     sb.append(getAtomSpec(atomSpecModel, false));
401     return sb.toString();
402   }
403
404   /**
405    * Makes a prefixed and valid Chimera attribute name. A jv_ prefix is applied
406    * for a 'Jalview' namespace, and any non-alphanumeric character is converted
407    * to an underscore.
408    * 
409    * @param featureType
410    * @return
411    * @see https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/setattr.html
412    */
413   protected static String makeAttributeName(String featureType)
414   {
415     StringBuilder sb = new StringBuilder();
416     if (featureType != null)
417     {
418       for (char c : featureType.toCharArray())
419       {
420         sb.append(Character.isLetterOrDigit(c) ? c : '_');
421       }
422     }
423     String attName = NAMESPACE_PREFIX + sb.toString();
424
425     /*
426      * Chimera treats an attribute name ending in 'color' as colour-valued;
427      * Jalview doesn't, so prevent this by appending an underscore
428      */
429     if (attName.toUpperCase().endsWith("COLOR"))
430     {
431       attName += "_";
432     }
433
434     return attName;
435   }
436
437   @Override
438   public String colourByChain()
439   {
440     return CMD_COLOUR_BY_CHAIN;
441   }
442
443   @Override
444   public String colourByCharge()
445   {
446     return CMD_COLOUR_BY_CHARGE;
447   }
448
449   @Override
450   public String getResidueSpec(String residue)
451   {
452     return "::" + residue;
453   }
454
455   @Override
456   public String setBackgroundColour(Color col)
457   {
458     // https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/set.html#bgcolor
459     return "set bgColor " + ColorUtils.toTkCode(col);
460   }
461
462   @Override
463   public String focusView()
464   {
465     // https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/focus.html
466     return "focus";
467   }
468
469   @Override
470   public String showChains(List<String> toShow)
471   {
472     /*
473      * Construct a chimera command like
474      * 
475      * ~display #*;~ribbon #*;ribbon :.A,:.B
476      */
477     StringBuilder cmd = new StringBuilder(64);
478     boolean first = true;
479     for (String chain : toShow)
480     {
481       String[] tokens = chain.split(":");
482       if (tokens.length == 2)
483       {
484         String showChainCmd = tokens[0] + ":." + tokens[1];
485         if (!first)
486         {
487           cmd.append(",");
488         }
489         cmd.append(showChainCmd);
490         first = false;
491       }
492     }
493
494     /*
495      * could append ";focus" to this command to resize the display to fill the
496      * window, but it looks more helpful not to (easier to relate chains to the
497      * whole)
498      */
499     final String command = "~display #*; ~ribbon #*; ribbon :"
500             + cmd.toString();
501     return command;
502   }
503
504   @Override
505   public String superposeStructures(AtomSpecModel spec, AtomSpecModel ref)
506   {
507     /*
508      * Form Chimera match command to match spec to ref
509      * 
510      * match #1:1-30.B,81-100.B@CA #0:21-40.A,61-90.A@CA
511      * 
512      * @see
513      * https://www.cgl.ucsf.edu/chimera/docs/UsersGuide/midas/match.html
514      */
515     StringBuilder cmd = new StringBuilder();
516     String atomSpec = getAtomSpec(spec, true);
517     String refSpec = getAtomSpec(ref, true);
518     cmd.append("match ").append(atomSpec).append(" ").append(refSpec);
519
520     /*
521      * show superposed residues as ribbon, others as chain
522      */
523     // fixme this should precede the loop over all alignments/structures
524     cmd.append(";~display all; chain @CA|P");
525     cmd.append("; ribbon ");
526     cmd.append(atomSpec).append("|").append(refSpec).append("; focus");
527
528     return cmd.toString();
529   }
530
531   @Override
532   public String openCommandFile(String path)
533   {
534     // https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/filetypes.html
535     return "open cmd:" + path;
536   }
537
538   @Override
539   public String saveSession(String filepath)
540   {
541     // https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/save.html
542     return "save " + filepath;
543   }
544
545   /**
546    * Returns the range(s) modelled by {@code atomSpec} formatted as a Chimera
547    * atomspec string, e.g.
548    * 
549    * <pre>
550    * #0:15.A,28.A,54.A,70-72.A|#1:2.A,6.A,11.A,13-14.A
551    * </pre>
552    * 
553    * where
554    * <ul>
555    * <li>#0 is a model number</li>
556    * <li>15 or 70-72 is a residue number, or range of residue numbers</li>
557    * <li>.A is a chain identifier</li>
558    * <li>residue ranges are separated by comma</li>
559    * <li>atomspecs for distinct models are separated by | (or)</li>
560    * </ul>
561    * 
562    * <pre>
563    * 
564    * @param model
565    * @param alphaOnly
566    * @return
567    * @see https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/frameatom_spec.html
568    */
569   @Override
570   public String getAtomSpec(AtomSpecModel atomSpec, boolean alphaOnly)
571   {
572     StringBuilder sb = new StringBuilder(128);
573     boolean firstModel = true;
574     for (Integer model : atomSpec.getModels())
575     {
576       if (!firstModel)
577       {
578         sb.append("|");
579       }
580       firstModel = false;
581       appendModel(sb, model, atomSpec, alphaOnly);
582     }
583     return sb.toString();
584   }
585
586   /**
587    * A helper method to append an atomSpec string for atoms in the given model
588    * 
589    * @param sb
590    * @param model
591    * @param atomSpec
592    * @param alphaOnly
593    */
594   protected void appendModel(StringBuilder sb, Integer model,
595           AtomSpecModel atomSpec, boolean alphaOnly)
596   {
597     sb.append("#").append(model).append(":");
598
599     boolean firstPositionForModel = true;
600
601     for (String chain : atomSpec.getChains(model))
602     {
603       chain = " ".equals(chain) ? chain : chain.trim();
604
605       List<int[]> rangeList = atomSpec.getRanges(model, chain);
606
607       /*
608        * sort ranges into ascending start position order
609        */
610       Collections.sort(rangeList, IntRangeComparator.ASCENDING);
611
612       int start = rangeList.isEmpty() ? 0 : rangeList.get(0)[0];
613       int end = rangeList.isEmpty() ? 0 : rangeList.get(0)[1];
614
615       Iterator<int[]> iterator = rangeList.iterator();
616       while (iterator.hasNext())
617       {
618         int[] range = iterator.next();
619         if (range[0] <= end + 1)
620         {
621           /*
622            * range overlaps or is contiguous with the last one
623            * - so just extend the end position, and carry on
624            * (unless this is the last in the list)
625            */
626           end = Math.max(end, range[1]);
627         }
628         else
629         {
630           /*
631            * we have a break so append the last range
632            */
633           appendRange(sb, start, end, chain, firstPositionForModel, false);
634           firstPositionForModel = false;
635           start = range[0];
636           end = range[1];
637         }
638       }
639
640       /*
641        * and append the last range
642        */
643       if (!rangeList.isEmpty())
644       {
645         appendRange(sb, start, end, chain, firstPositionForModel, false);
646         firstPositionForModel = false;
647       }
648     }
649     if (alphaOnly)
650     {
651       /*
652        * restrict to alpha carbon, no alternative locations
653        * (needed to ensuring matching atom counts for superposition)
654        */
655       sb.append("@CA|P").append(NO_ALTLOCS);
656     }
657   }
658
659   @Override
660   public String showBackbone()
661   {
662     return "~display all;chain @CA|P";
663   }
664
665 }