JAL-3518 more pull up / test coverage of structure command generation
[jalview.git] / src / jalview / ext / rbvi / chimera / JalviewChimeraBinding.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.AlignmentViewPanel;
24 import jalview.api.structures.JalviewStructureDisplayI;
25 import jalview.bin.Cache;
26 import jalview.datamodel.AlignmentI;
27 import jalview.datamodel.HiddenColumns;
28 import jalview.datamodel.PDBEntry;
29 import jalview.datamodel.SearchResultMatchI;
30 import jalview.datamodel.SearchResultsI;
31 import jalview.datamodel.SequenceFeature;
32 import jalview.datamodel.SequenceI;
33 import jalview.gui.StructureViewer.ViewerType;
34 import jalview.httpserver.AbstractRequestHandler;
35 import jalview.io.DataSourceType;
36 import jalview.structure.AtomSpec;
37 import jalview.structure.StructureCommandsI.SuperposeData;
38 import jalview.structure.StructureSelectionManager;
39 import jalview.structures.models.AAStructureBindingModel;
40 import jalview.util.MessageManager;
41
42 import java.io.File;
43 import java.io.FileOutputStream;
44 import java.io.IOException;
45 import java.io.PrintWriter;
46 import java.net.BindException;
47 import java.util.ArrayList;
48 import java.util.BitSet;
49 import java.util.Collections;
50 import java.util.Iterator;
51 import java.util.LinkedHashMap;
52 import java.util.List;
53 import java.util.Map;
54
55 import ext.edu.ucsf.rbvi.strucviz2.ChimeraManager;
56 import ext.edu.ucsf.rbvi.strucviz2.ChimeraModel;
57 import ext.edu.ucsf.rbvi.strucviz2.StructureManager;
58 import ext.edu.ucsf.rbvi.strucviz2.StructureManager.ModelType;
59
60 public abstract class JalviewChimeraBinding extends AAStructureBindingModel
61 {
62   public static final String CHIMERA_FEATURE_GROUP = "Chimera";
63
64   // Chimera clause to exclude alternate locations in atom selection
65   private static final String NO_ALTLOCS = "&~@.B-Z&~@.2-9";
66
67   private static final boolean debug = false;
68
69   private static final String PHOSPHORUS = "P";
70
71   private static final String ALPHACARBON = "CA";
72
73   /*
74    * Object through which we talk to Chimera
75    */
76   private ChimeraManager chimeraManager;
77
78   /*
79    * Object which listens to Chimera notifications
80    */
81   private AbstractRequestHandler chimeraListener;
82
83   /*
84    * Map of ChimeraModel objects keyed by PDB full local file name
85    */
86   protected Map<String, List<ChimeraModel>> chimeraMaps = new LinkedHashMap<>();
87
88   String lastHighlightCommand;
89
90   private Thread chimeraMonitor;
91
92   /**
93    * Open a PDB structure file in Chimera and set up mappings from Jalview.
94    * 
95    * We check if the PDB model id is already loaded in Chimera, if so don't reopen
96    * it. This is the case if Chimera has opened a saved session file.
97    * 
98    * @param pe
99    * @return
100    */
101   public boolean openFile(PDBEntry pe)
102   {
103     String file = pe.getFile();
104     try
105     {
106       List<ChimeraModel> modelsToMap = new ArrayList<>();
107       List<ChimeraModel> oldList = chimeraManager.getModelList();
108       boolean alreadyOpen = false;
109
110       /*
111        * If Chimera already has this model, don't reopen it, but do remap it.
112        */
113       for (ChimeraModel open : oldList)
114       {
115         if (open.getModelName().equals(pe.getId()))
116         {
117           alreadyOpen = true;
118           modelsToMap.add(open);
119         }
120       }
121
122       /*
123        * If Chimera doesn't yet have this model, ask it to open it, and retrieve
124        * the model name(s) added by Chimera.
125        */
126       if (!alreadyOpen)
127       {
128         chimeraManager.openModel(file, pe.getId(), ModelType.PDB_MODEL);
129         addChimeraModel(pe, modelsToMap);
130       }
131
132       chimeraMaps.put(file, modelsToMap);
133
134       if (getSsm() != null)
135       {
136         getSsm().addStructureViewerListener(this);
137       }
138       return true;
139     } catch (Exception q)
140     {
141       log("Exception when trying to open model " + file + "\n"
142               + q.toString());
143       q.printStackTrace();
144     }
145     return false;
146   }
147
148   /**
149    * Adds the ChimeraModel corresponding to the given PDBEntry, based on model
150    * name matching PDB id
151    * 
152    * @param pe
153    * @param modelsToMap
154    */
155   protected void addChimeraModel(PDBEntry pe,
156           List<ChimeraModel> modelsToMap)
157   {
158     /*
159      * Chimera: query for actual models and find the one with
160      * matching model name - already set in viewer.openModel()
161      */
162     List<ChimeraModel> newList = chimeraManager.getModelList();
163     // JAL-1728 newList.removeAll(oldList) does not work
164     for (ChimeraModel cm : newList)
165     {
166       if (cm.getModelName().equals(pe.getId()))
167       {
168         modelsToMap.add(cm);
169       }
170     }
171   }
172
173   /**
174    * Constructor
175    * 
176    * @param ssm
177    * @param pdbentry
178    * @param sequenceIs
179    * @param protocol
180    */
181   public JalviewChimeraBinding(StructureSelectionManager ssm,
182           PDBEntry[] pdbentry, SequenceI[][] sequenceIs,
183           DataSourceType protocol)
184   {
185     super(ssm, pdbentry, sequenceIs, protocol);
186     chimeraManager = new ChimeraManager(new StructureManager(true));
187     chimeraManager.setChimeraX(ViewerType.CHIMERAX.equals(getViewerType()));
188     setStructureCommands(new ChimeraCommands());
189   }
190
191   @Override
192   protected ViewerType getViewerType()
193   {
194     return ViewerType.CHIMERA;
195   }
196
197   /**
198    * Starts a thread that waits for the Chimera process to finish, so that we can
199    * then close the associated resources. This avoids leaving orphaned Chimera
200    * viewer panels in Jalview if the user closes Chimera.
201    */
202   protected void startChimeraProcessMonitor()
203   {
204     final Process p = chimeraManager.getChimeraProcess();
205     chimeraMonitor = new Thread(new Runnable()
206     {
207
208       @Override
209       public void run()
210       {
211         try
212         {
213           p.waitFor();
214           JalviewStructureDisplayI display = getViewer();
215           if (display != null)
216           {
217             display.closeViewer(false);
218           }
219         } catch (InterruptedException e)
220         {
221           // exit thread if Chimera Viewer is closed in Jalview
222         }
223       }
224     });
225     chimeraMonitor.start();
226   }
227
228   /**
229    * Start a dedicated HttpServer to listen for Chimera notifications, and tell it
230    * to start listening
231    */
232   public void startChimeraListener()
233   {
234     try
235     {
236       chimeraListener = new ChimeraListener(this);
237       chimeraManager.startListening(chimeraListener.getUri());
238     } catch (BindException e)
239     {
240       System.err.println(
241               "Failed to start Chimera listener: " + e.getMessage());
242     }
243   }
244
245   /**
246    * Close down the Jalview viewer and listener, and (optionally) the associated
247    * Chimera window.
248    */
249   public void closeViewer(boolean closeChimera)
250   {
251     getSsm().removeStructureViewerListener(this, this.getStructureFiles());
252     if (closeChimera)
253     {
254       chimeraManager.exitChimera();
255     }
256     if (this.chimeraListener != null)
257     {
258       chimeraListener.shutdown();
259       chimeraListener = null;
260     }
261     chimeraManager = null;
262
263     if (chimeraMonitor != null)
264     {
265       chimeraMonitor.interrupt();
266     }
267     releaseUIResources();
268   }
269
270   /**
271    * {@inheritDoc}
272    */
273   public String superposeStructures(AlignmentI[] _alignment,
274           int[] _refStructure, HiddenColumns[] _hiddenCols)
275   {
276     // TODO delete method
277     StringBuilder allComs = new StringBuilder(128);
278     String[] files = getStructureFiles();
279
280     if (!waitForFileLoad(files))
281     {
282       return null;
283     }
284
285     refreshPdbEntries();
286     StringBuilder selectioncom = new StringBuilder(256);
287     boolean chimeraX = chimeraManager.isChimeraX();
288     for (int a = 0; a < _alignment.length; a++)
289     {
290       int refStructure = _refStructure[a];
291       AlignmentI alignment = _alignment[a];
292       HiddenColumns hiddenCols = _hiddenCols[a];
293
294       if (refStructure >= files.length)
295       {
296         System.err.println("Ignoring invalid reference structure value "
297                 + refStructure);
298         refStructure = -1;
299       }
300
301       /*
302        * 'matched' bit i will be set for visible alignment columns i where
303        * all sequences have a residue with a mapping to the PDB structure
304        */
305       BitSet matched = new BitSet();
306       for (int m = 0; m < alignment.getWidth(); m++)
307       {
308         if (hiddenCols == null || hiddenCols.isVisible(m))
309         {
310           matched.set(m);
311         }
312       }
313
314       SuperposeData[] structures = new SuperposeData[files.length];
315       for (int f = 0; f < files.length; f++)
316       {
317         structures[f] = new SuperposeData(alignment.getWidth(), f);
318       }
319
320       /*
321        * Calculate the superposable alignment columns ('matched'), and the
322        * corresponding structure residue positions (structures.pdbResNo)
323        */
324       int candidateRefStructure = findSuperposableResidues(alignment,
325               matched, structures);
326       if (refStructure < 0)
327       {
328         /*
329          * If no reference structure was specified, pick the first one that has
330          * a mapping in the alignment
331          */
332         refStructure = candidateRefStructure;
333       }
334
335       int nmatched = matched.cardinality();
336       if (nmatched < 4)
337       {
338         return MessageManager.formatMessage("label.insufficient_residues",
339                 nmatched);
340       }
341
342       /*
343        * Generate select statements to select regions to superimpose structures
344        */
345       String[] selcom = new String[files.length];
346       for (int pdbfnum = 0; pdbfnum < files.length; pdbfnum++)
347       {
348         final int modelNo = pdbfnum + (chimeraX ? 1 : 0);
349         // todo correct resolution to model number
350         String chainCd = "." + structures[pdbfnum].chain;
351         int lpos = -1;
352         boolean run = false;
353         StringBuilder molsel = new StringBuilder();
354         if (chimeraX)
355         {
356           molsel.append("/" + structures[pdbfnum].chain + ":");
357         }
358
359         int nextColumnMatch = matched.nextSetBit(0);
360         while (nextColumnMatch != -1)
361         {
362           int pdbResNum = structures[pdbfnum].pdbResNo[nextColumnMatch];
363           if (lpos != pdbResNum - 1)
364           {
365             /*
366              * discontiguous - append last residue now
367              */
368             if (lpos != -1)
369             {
370               molsel.append(String.valueOf(lpos));
371               if (!chimeraX)
372               {
373                 molsel.append(chainCd);
374               }
375               molsel.append(",");
376             }
377             run = false;
378           }
379           else
380           {
381             /*
382              * extending a contiguous run
383              */
384             if (!run)
385             {
386               /*
387                * start the range selection
388                */
389               molsel.append(String.valueOf(lpos));
390               molsel.append("-");
391             }
392             run = true;
393           }
394           lpos = pdbResNum;
395           nextColumnMatch = matched.nextSetBit(nextColumnMatch + 1);
396         }
397
398         /*
399          * and terminate final selection
400          */
401         if (lpos != -1)
402         {
403           molsel.append(String.valueOf(lpos));
404           if (!chimeraX)
405           {
406             molsel.append(chainCd);
407           }
408         }
409         if (molsel.length() > 1)
410         {
411           selcom[pdbfnum] = molsel.toString();
412           selectioncom.append("#").append(String.valueOf(modelNo));
413           if (!chimeraX)
414           {
415             selectioncom.append(":");
416           }
417           selectioncom.append(selcom[pdbfnum]);
418           // selectioncom.append(" ");
419           if (pdbfnum < files.length - 1)
420           {
421             selectioncom.append("|");
422           }
423         }
424         else
425         {
426           selcom[pdbfnum] = null;
427         }
428       }
429
430       StringBuilder command = new StringBuilder(256);
431       for (int pdbfnum = 0; pdbfnum < files.length; pdbfnum++)
432       {
433         final int modelNo = pdbfnum + (chimeraX ? 1 : 0);
434         if (pdbfnum == refStructure || selcom[pdbfnum] == null
435                 || selcom[refStructure] == null)
436         {
437           continue;
438         }
439         if (command.length() > 0)
440         {
441           command.append(";");
442         }
443
444         /*
445          * Form Chimera match command, from the 'new' structure to the
446          * 'reference' structure e.g. (50 residues, chain B/A, alphacarbons):
447          * 
448          * match #1:1-30.B,81-100.B@CA #0:21-40.A,61-90.A@CA
449          * 
450          * @see
451          * https://www.cgl.ucsf.edu/chimera/docs/UsersGuide/midas/match.html
452          */
453         command.append(chimeraX ? "align " : "match ");
454         command.append(getModelSpec(modelNo));
455         if (!chimeraX)
456         {
457           command.append(":");
458         }
459         command.append(selcom[pdbfnum]);
460         command.append("@").append(
461                 structures[pdbfnum].isRna ? PHOSPHORUS : ALPHACARBON);
462         // JAL-1757 exclude alternate CA locations - ChimeraX syntax tbd
463         if (!chimeraX)
464         {
465           command.append(NO_ALTLOCS);
466         }
467         command.append(chimeraX ? " toAtoms " : " ")
468                 .append(getModelSpec(refStructure + (chimeraX ? 1 : 0)));
469         if (!chimeraX)
470         {
471           command.append(":");
472         }
473         command.append(selcom[refStructure]);
474         command.append("@").append(
475                 structures[refStructure].isRna ? PHOSPHORUS : ALPHACARBON);
476         if (!chimeraX)
477         {
478           command.append(NO_ALTLOCS);
479         }
480       }
481       if (selectioncom.length() > 0)
482       {
483         if (debug)
484         {
485           System.out.println("Select regions:\n" + selectioncom.toString());
486           System.out.println(
487                   "Superimpose command(s):\n" + command.toString());
488         }
489         // allComs.append("~display all; ");
490         // if (chimeraX)
491         // {
492         // allComs.append("show ").append(selectioncom.toString())
493         // .append(" pbonds");
494         // }
495         // else
496         // {
497         // allComs.append("chain @CA|P; ribbon ");
498         // allComs.append(selectioncom.toString());
499         // }
500         if (allComs.length() > 0) {
501           allComs.append(";");
502         }
503         allComs.append(command.toString());
504       }
505     }
506
507     String error = null;
508     if (selectioncom.length() > 0)
509     {
510       // TODO: visually distinguish regions that were superposed
511       if (selectioncom.substring(selectioncom.length() - 1).equals("|"))
512       {
513         selectioncom.setLength(selectioncom.length() - 1);
514       }
515       if (debug)
516       {
517         System.out.println("Select regions:\n" + selectioncom.toString());
518       }
519       allComs.append(";~display all; ");
520       if (chimeraX)
521       {
522         allComs.append("show @CA|P pbonds; show ")
523                 .append(selectioncom.toString()).append(" ribbons; view");
524       }
525       else
526       {
527         allComs.append("chain @CA|P; ribbon ");
528         allComs.append(selectioncom.toString()).append("; focus");
529       }
530       // allComs.append("; ~display all; chain @CA|P; ribbon ")
531       // .append(selectioncom.toString()).append("; focus");
532       List<String> chimeraReplies = executeCommand(allComs.toString(),
533               true);
534       for (String reply : chimeraReplies)
535       {
536         if (reply.toLowerCase().contains("unequal numbers of atoms"))
537         {
538           error = reply;
539         }
540       }
541     }
542     return error;
543   }
544
545   /**
546    * Helper method to construct model spec in Chimera format:
547    * <ul>
548    * <li>#0 (#1 etc) for a PDB file with no sub-models</li>
549    * <li>#0.1 (#1.1 etc) for a PDB file with sub-models</li>
550    * <ul>
551    * Note for now we only ever choose the first of multiple models. This
552    * corresponds to the hard-coded Jmol equivalent (compare {1.1}). Refactor in
553    * future if there is a need to select specific sub-models.
554    * 
555    * @param pdbfnum
556    * @return
557    */
558   protected String getModelSpec(int pdbfnum)
559   {
560     if (pdbfnum < 0 || pdbfnum >= getPdbCount())
561     {
562       return "#" + pdbfnum; // temp hack for ChimeraX
563     }
564
565     /*
566      * For now, the test for having sub-models is whether multiple Chimera
567      * models are mapped for the PDB file; the models are returned as a response
568      * to the Chimera command 'list models type molecule', see
569      * ChimeraManager.getModelList().
570      */
571     List<ChimeraModel> maps = chimeraMaps.get(getStructureFiles()[pdbfnum]);
572     boolean hasSubModels = maps != null && maps.size() > 1;
573     return "#" + String.valueOf(pdbfnum) + (hasSubModels ? ".1" : "");
574   }
575
576   /**
577    * Launch Chimera, unless an instance linked to this object is already
578    * running. Returns true if Chimera is successfully launched, or already
579    * running, else false.
580    * 
581    * @return
582    */
583   public boolean launchChimera()
584   {
585     if (chimeraManager.isChimeraLaunched())
586     {
587       return true;
588     }
589
590     boolean launched = chimeraManager.launchChimera(getChimeraPaths());
591     if (launched)
592     {
593       startChimeraProcessMonitor();
594     }
595     else
596     {
597       log("Failed to launch Chimera!");
598     }
599     return launched;
600   }
601
602   /**
603    * Returns a list of candidate paths to the Chimera program executable
604    * 
605    * @return
606    */
607   protected List<String> getChimeraPaths()
608   {
609     return StructureManager.getChimeraPaths(false);
610   }
611
612   /**
613    * Answers true if the Chimera process is still running, false if ended or not
614    * started.
615    * 
616    * @return
617    */
618   public boolean isChimeraRunning()
619   {
620     return chimeraManager.isChimeraLaunched();
621   }
622
623   /**
624    * Send a command to Chimera, and optionally log and return any responses.
625    * 
626    * @param command
627    * @param getResponse
628    */
629   @Override
630   public List<String> executeCommand(final String command,
631           boolean getResponse)
632   {
633     if (chimeraManager == null || command == null)
634     {
635       // ? thread running after viewer shut down
636       return null;
637     }
638     List<String> reply = null;
639     // trim command or it may never find a match in the replyLog!!
640     List<String> lastReply = chimeraManager
641             .sendChimeraCommand(command.trim(), getResponse);
642     if (getResponse)
643     {
644       reply = lastReply;
645       if (debug)
646       {
647         log("Response from command ('" + command + "') was:\n" + lastReply);
648       }
649     }
650
651     return reply;
652   }
653
654   /**
655    * @param command
656    */
657   protected void executeWhenReady(String command)
658   {
659     waitForChimera();
660     executeCommand(command, false);
661     waitForChimera();
662   }
663
664   private void waitForChimera()
665   {
666     while (chimeraManager != null && chimeraManager.isBusy())
667     {
668       try
669       {
670         Thread.sleep(15);
671       } catch (InterruptedException q)
672       {
673       }
674     }
675   }
676
677   @Override
678   public synchronized String[] getStructureFiles()
679   {
680     if (chimeraManager == null)
681     {
682       return new String[0];
683     }
684
685     return chimeraMaps.keySet()
686             .toArray(modelFileNames = new String[chimeraMaps.size()]);
687   }
688
689   /**
690    * Construct and send a command to highlight zero, one or more atoms. We do this
691    * by sending an "rlabel" command to show the residue label at that position.
692    */
693   @Override
694   public void highlightAtoms(List<AtomSpec> atoms)
695   {
696     if (atoms == null || atoms.size() == 0)
697     {
698       return;
699     }
700
701     boolean forChimeraX = chimeraManager.isChimeraX();
702     StringBuilder cmd = new StringBuilder(128);
703     boolean first = true;
704     boolean found = false;
705
706     for (AtomSpec atom : atoms)
707     {
708       int pdbResNum = atom.getPdbResNum();
709       String chain = atom.getChain();
710       String pdbfile = atom.getPdbFile();
711       List<ChimeraModel> cms = chimeraMaps.get(pdbfile);
712       if (cms != null && !cms.isEmpty())
713       {
714         if (first)
715         {
716           cmd.append(forChimeraX ? "label #" : "rlabel #");
717         }
718         else
719         {
720           cmd.append(",");
721         }
722         first = false;
723         if (forChimeraX)
724         {
725           cmd.append(cms.get(0).getModelNumber())
726                   .append("/").append(chain).append(":").append(pdbResNum);
727         }
728         else
729         {
730           cmd.append(cms.get(0).getModelNumber())
731                   .append(":").append(pdbResNum);
732           if (!chain.equals(" ") && !forChimeraX)
733           {
734             cmd.append(".").append(chain);
735           }
736         }
737         found = true;
738       }
739     }
740     String command = cmd.toString();
741
742     /*
743      * avoid repeated commands for the same residue
744      */
745     if (command.equals(lastHighlightCommand))
746     {
747       return;
748     }
749
750     /*
751      * unshow the label for the previous residue
752      */
753     if (lastHighlightCommand != null)
754     {
755       chimeraManager.sendChimeraCommand("~" + lastHighlightCommand, false);
756     }
757     if (found)
758     {
759       chimeraManager.sendChimeraCommand(command, false);
760     }
761     this.lastHighlightCommand = command;
762   }
763
764   /**
765    * Query Chimera for its current selection, and highlight it on the alignment
766    */
767   public void highlightChimeraSelection()
768   {
769     /*
770      * Ask Chimera for its current selection
771      */
772     List<String> selection = chimeraManager.getSelectedResidueSpecs();
773
774     /*
775      * Parse model number, residue and chain for each selected position,
776      * formatted as #0:123.A or #1.2:87.B (#model.submodel:residue.chain)
777      */
778     List<AtomSpec> atomSpecs = convertStructureResiduesToAlignment(
779             selection);
780
781     /*
782      * Broadcast the selection (which may be empty, if the user just cleared all
783      * selections)
784      */
785     getSsm().mouseOverStructure(atomSpecs);
786   }
787
788   /**
789    * Converts a list of Chimera atomspecs to a list of AtomSpec representing the
790    * corresponding residues (if any) in Jalview
791    * 
792    * @param structureSelection
793    * @return
794    */
795   protected List<AtomSpec> convertStructureResiduesToAlignment(
796           List<String> structureSelection)
797   {
798     boolean chimeraX = chimeraManager.isChimeraX();
799     List<AtomSpec> atomSpecs = new ArrayList<>();
800     for (String atomSpec : structureSelection)
801     {
802       try
803       {
804         AtomSpec spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
805         String pdbfilename = getPdbFileForModel(spec.getModelNumber());
806         spec.setPdbFile(pdbfilename);
807         atomSpecs.add(spec);
808       } catch (IllegalArgumentException e)
809       {
810         System.err.println("Failed to parse atomspec: " + atomSpec);
811       }
812     }
813     return atomSpecs;
814   }
815
816   /**
817    * @param modelId
818    * @return
819    */
820   protected String getPdbFileForModel(int modelId)
821   {
822     /*
823      * Work out the pdbfilename from the model number
824      */
825     String pdbfilename = modelFileNames[0];
826     findfileloop: for (String pdbfile : this.chimeraMaps.keySet())
827     {
828       for (ChimeraModel cm : chimeraMaps.get(pdbfile))
829       {
830         if (cm.getModelNumber() == modelId)
831         {
832           pdbfilename = pdbfile;
833           break findfileloop;
834         }
835       }
836     }
837     return pdbfilename;
838   }
839
840   private void log(String message)
841   {
842     System.err.println("## Chimera log: " + message);
843   }
844
845   /**
846    * Ask Chimera to save its session to the given file. Returns true if
847    * successful, else false.
848    * 
849    * @param filepath
850    * @return
851    */
852   public boolean saveSession(String filepath)
853   {
854     if (isChimeraRunning())
855     {
856       /*
857        * Chimera:  https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/save.html
858        * ChimeraX: https://www.cgl.ucsf.edu/chimerax/docs/user/commands/save.html
859        */
860       String command = getCommandGenerator().saveSession(filepath);
861       List<String> reply = chimeraManager.sendChimeraCommand(command, true);
862       if (reply.contains("Session written"))
863       {
864         return true;
865       }
866       else
867       {
868         Cache.log
869                 .error("Error saving Chimera session: " + reply.toString());
870       }
871     }
872     return false;
873   }
874
875   /**
876    * Ask Chimera to open a session file. Returns true if successful, else false.
877    * The filename must have a .py (Chimera) or .cxs (ChimeraX) extension for
878    * this command to work.
879    * 
880    * @param filepath
881    * @return
882    */
883   public boolean openSession(String filepath)
884   {
885     /*
886      * Chimera:  https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/open.html
887      * ChimeraX: https://www.cgl.ucsf.edu/chimerax/docs/user/commands/open.html
888      */
889     executeCommand("open " + filepath, true);
890     // todo: test for failure - how?
891     return true;
892   }
893
894   /**
895    * Send a 'show' command for all atoms in the currently selected columns
896    * 
897    * TODO: pull up to abstract structure viewer interface
898    * 
899    * @param vp
900    */
901   public void highlightSelection(AlignmentViewPanel vp)
902   {
903     List<Integer> cols = vp.getAlignViewport().getColumnSelection()
904             .getSelected();
905     AlignmentI alignment = vp.getAlignment();
906     StructureSelectionManager sm = getSsm();
907     for (SequenceI seq : alignment.getSequences())
908     {
909       /*
910        * convert selected columns into sequence positions
911        */
912       int[] positions = new int[cols.size()];
913       int i = 0;
914       for (Integer col : cols)
915       {
916         positions[i++] = seq.findPosition(col);
917       }
918       sm.highlightStructure(this, seq, positions);
919     }
920   }
921
922   /**
923    * Constructs and send commands to Chimera to set attributes on residues for
924    * features visible in Jalview
925    * 
926    * @param avp
927    * @return
928    */
929   public int sendFeaturesToViewer(AlignmentViewPanel avp)
930   {
931     // TODO refactor as required to pull up to an interface
932     String[] files = getStructureFiles();
933     if (files == null)
934     {
935       return 0;
936     }
937
938     String[] commands = getCommandGenerator()
939             .setAttributesForFeatures(getSsm(), files, getSequence(), avp);
940     if (commands.length > 10)
941     {
942       sendCommandsByFile(commands);
943     }
944     else
945     {
946       for (String command : commands)
947       {
948         sendAsynchronousCommand(command, null);
949       }
950     }
951     return commands.length;
952   }
953
954   /**
955    * Write commands to a temporary file, and send a command to Chimera to open the
956    * file as a commands script. For use when sending a large number of separate
957    * commands would overload the REST interface mechanism.
958    * 
959    * @param commands
960    */
961   protected void sendCommandsByFile(String[] commands)
962   {
963     try
964     {
965       File tmp = File.createTempFile("chim", getCommandFileExtension());
966       tmp.deleteOnExit();
967       PrintWriter out = new PrintWriter(new FileOutputStream(tmp));
968       for (String command : commands)
969       {
970         out.println(command);
971       }
972       out.flush();
973       out.close();
974       String path = tmp.getAbsolutePath();
975       String command = getCommandGenerator().openCommandFile(path);
976       sendAsynchronousCommand(command, null);
977     } catch (IOException e)
978     {
979       System.err.println("Sending commands to Chimera via file failed with "
980               + e.getMessage());
981     }
982   }
983
984   /**
985    * Returns the file extension required for a file of commands to be read by
986    * the structure viewer
987    * @return
988    */
989   protected String getCommandFileExtension()
990   {
991     return ".com";
992   }
993
994   /**
995    * Get Chimera residues which have the named attribute, find the mapped
996    * positions in the Jalview sequence(s), and set as sequence features
997    * 
998    * @param attName
999    * @param alignmentPanel
1000    */
1001   public void copyStructureAttributesToFeatures(String attName,
1002           AlignmentViewPanel alignmentPanel)
1003   {
1004     // todo pull up to AAStructureBindingModel (and interface?)
1005
1006     /*
1007      * ask Chimera to list residues with the attribute, reporting its value
1008      */
1009     // this alternative command
1010     // list residues spec ':*/attName' attr attName
1011     // doesn't report 'None' values (which is good), but
1012     // fails for 'average.bfactor' (which is bad):
1013
1014     String cmd = "list residues attr '" + attName + "'";
1015     List<String> residues = executeCommand(cmd, true);
1016
1017     boolean featureAdded = createFeaturesForAttributes(attName, residues);
1018     if (featureAdded)
1019     {
1020       alignmentPanel.getFeatureRenderer().featuresAdded();
1021     }
1022   }
1023
1024   /**
1025    * Create features in Jalview for the given attribute name and structure
1026    * residues.
1027    * 
1028    * <pre>
1029    * The residue list should be 0, 1 or more reply lines of the format: 
1030    *     residue id #0:5.A isHelix -155.000836316 index 5 
1031    * or 
1032    *     residue id #0:6.A isHelix None
1033    * </pre>
1034    * 
1035    * @param attName
1036    * @param residues
1037    * @return
1038    */
1039   protected boolean createFeaturesForAttributes(String attName,
1040           List<String> residues)
1041   {
1042     boolean featureAdded = false;
1043     String featureGroup = getViewerFeatureGroup();
1044     boolean chimeraX = chimeraManager.isChimeraX();
1045
1046     for (String residue : residues)
1047     {
1048       AtomSpec spec = null;
1049       String[] tokens = residue.split(" ");
1050       if (tokens.length < 5)
1051       {
1052         continue;
1053       }
1054       String atomSpec = tokens[2];
1055       String attValue = tokens[4];
1056
1057       /*
1058        * ignore 'None' (e.g. for phi) or 'False' (e.g. for isHelix)
1059        */
1060       if ("None".equalsIgnoreCase(attValue)
1061               || "False".equalsIgnoreCase(attValue))
1062       {
1063         continue;
1064       }
1065
1066       try
1067       {
1068         spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
1069       } catch (IllegalArgumentException e)
1070       {
1071         System.err.println("Problem parsing atomspec " + atomSpec);
1072         continue;
1073       }
1074
1075       String chainId = spec.getChain();
1076       String description = attValue;
1077       float score = Float.NaN;
1078       try
1079       {
1080         score = Float.valueOf(attValue);
1081         description = chainId;
1082       } catch (NumberFormatException e)
1083       {
1084         // was not a float value
1085       }
1086
1087       String pdbFile = getPdbFileForModel(spec.getModelNumber());
1088       spec.setPdbFile(pdbFile);
1089
1090       List<AtomSpec> atoms = Collections.singletonList(spec);
1091
1092       /*
1093        * locate the mapped position in the alignment (if any)
1094        */
1095       SearchResultsI sr = getSsm()
1096               .findAlignmentPositionsForStructurePositions(atoms);
1097
1098       /*
1099        * expect one matched alignment position, or none 
1100        * (if the structure position is not mapped)
1101        */
1102       for (SearchResultMatchI m : sr.getResults())
1103       {
1104         SequenceI seq = m.getSequence();
1105         int start = m.getStart();
1106         int end = m.getEnd();
1107         SequenceFeature sf = new SequenceFeature(attName, description,
1108                 start, end, score, featureGroup);
1109         // todo: should SequenceFeature have an explicit property for chain?
1110         // note: repeating the action shouldn't duplicate features
1111         featureAdded |= seq.addSequenceFeature(sf);
1112       }
1113     }
1114     return featureAdded;
1115   }
1116
1117   /**
1118    * Answers the feature group name to apply to features created in Jalview from
1119    * Chimera attributes
1120    * 
1121    * @return
1122    */
1123   protected String getViewerFeatureGroup()
1124   {
1125     // todo pull up to interface
1126     return CHIMERA_FEATURE_GROUP;
1127   }
1128
1129   @Override
1130   public int getModelNoForFile(String pdbFile)
1131   {
1132     List<ChimeraModel> foundModels = chimeraMaps.get(pdbFile);
1133     if (foundModels != null && !foundModels.isEmpty())
1134     {
1135       return foundModels.get(0).getModelNumber();
1136     }
1137     return -1;
1138   }
1139
1140   /**
1141    * Answers a (possibly empty) list of attribute names in Chimera[X], excluding
1142    * any which were added from Jalview
1143    * 
1144    * @return
1145    */
1146   public List<String> getChimeraAttributes()
1147   {
1148     List<String> atts = chimeraManager.getAttrList();
1149     Iterator<String> it = atts.iterator();
1150     while (it.hasNext())
1151     {
1152       if (it.next().startsWith(ChimeraCommands.NAMESPACE_PREFIX))
1153       {
1154         /*
1155          * attribute added from Jalview - exclude it
1156          */
1157         it.remove();
1158       }
1159     }
1160     return atts;
1161   }
1162
1163   /**
1164    * Returns the file extension to use for a saved viewer session file
1165    * 
1166    * @return
1167    */
1168   public String getSessionFileExtension()
1169   {
1170     return ".py";
1171   }
1172
1173   public String getHelpURL()
1174   {
1175     return "https://www.cgl.ucsf.edu/chimera/docs/UsersGuide";
1176   }
1177 }