JAL-3518 pull up closeViewer() and external viewer process monitor
[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 java.io.File;
24 import java.io.FileOutputStream;
25 import java.io.IOException;
26 import java.io.PrintWriter;
27 import java.net.BindException;
28 import java.util.ArrayList;
29 import java.util.Collections;
30 import java.util.Iterator;
31 import java.util.LinkedHashMap;
32 import java.util.List;
33 import java.util.Map;
34
35 import ext.edu.ucsf.rbvi.strucviz2.ChimeraManager;
36 import ext.edu.ucsf.rbvi.strucviz2.ChimeraModel;
37 import ext.edu.ucsf.rbvi.strucviz2.StructureManager;
38 import ext.edu.ucsf.rbvi.strucviz2.StructureManager.ModelType;
39 import jalview.api.AlignmentViewPanel;
40 import jalview.bin.Cache;
41 import jalview.datamodel.AlignmentI;
42 import jalview.datamodel.PDBEntry;
43 import jalview.datamodel.SearchResultMatchI;
44 import jalview.datamodel.SearchResultsI;
45 import jalview.datamodel.SequenceFeature;
46 import jalview.datamodel.SequenceI;
47 import jalview.gui.StructureViewer.ViewerType;
48 import jalview.httpserver.AbstractRequestHandler;
49 import jalview.io.DataSourceType;
50 import jalview.structure.AtomSpec;
51 import jalview.structure.AtomSpecModel;
52 import jalview.structure.StructureCommand;
53 import jalview.structure.StructureCommandI;
54 import jalview.structure.StructureSelectionManager;
55 import jalview.structures.models.AAStructureBindingModel;
56
57 public abstract class JalviewChimeraBinding extends AAStructureBindingModel
58 {
59   public static final String CHIMERA_SESSION_EXTENSION = ".py";
60
61   public static final String CHIMERA_FEATURE_GROUP = "Chimera";
62
63   /*
64    * Object through which we talk to Chimera
65    */
66   private ChimeraManager chimeraManager;
67
68   /*
69    * Object which listens to Chimera notifications
70    */
71   private AbstractRequestHandler chimeraListener;
72
73   /*
74    * Map of ChimeraModel objects keyed by PDB full local file name
75    */
76   protected Map<String, List<ChimeraModel>> chimeraMaps = new LinkedHashMap<>();
77
78   String lastHighlightCommand;
79
80   /**
81    * Open a PDB structure file in Chimera and set up mappings from Jalview.
82    * 
83    * We check if the PDB model id is already loaded in Chimera, if so don't reopen
84    * it. This is the case if Chimera has opened a saved session file.
85    * 
86    * @param pe
87    * @return
88    */
89   public boolean openFile(PDBEntry pe)
90   {
91     String file = pe.getFile();
92     try
93     {
94       List<ChimeraModel> modelsToMap = new ArrayList<>();
95       List<ChimeraModel> oldList = chimeraManager.getModelList();
96       boolean alreadyOpen = false;
97
98       /*
99        * If Chimera already has this model, don't reopen it, but do remap it.
100        */
101       for (ChimeraModel open : oldList)
102       {
103         if (open.getModelName().equals(pe.getId()))
104         {
105           alreadyOpen = true;
106           modelsToMap.add(open);
107         }
108       }
109
110       /*
111        * If Chimera doesn't yet have this model, ask it to open it, and retrieve
112        * the model name(s) added by Chimera.
113        */
114       if (!alreadyOpen)
115       {
116         chimeraManager.openModel(file, pe.getId(), ModelType.PDB_MODEL);
117         addChimeraModel(pe, modelsToMap);
118       }
119
120       chimeraMaps.put(file, modelsToMap);
121
122       if (getSsm() != null)
123       {
124         getSsm().addStructureViewerListener(this);
125       }
126       return true;
127     } catch (Exception q)
128     {
129       log("Exception when trying to open model " + file + "\n"
130               + q.toString());
131       q.printStackTrace();
132     }
133     return false;
134   }
135
136   /**
137    * Adds the ChimeraModel corresponding to the given PDBEntry, based on model
138    * name matching PDB id
139    * 
140    * @param pe
141    * @param modelsToMap
142    */
143   protected void addChimeraModel(PDBEntry pe,
144           List<ChimeraModel> modelsToMap)
145   {
146     /*
147      * Chimera: query for actual models and find the one with
148      * matching model name - already set in viewer.openModel()
149      */
150     List<ChimeraModel> newList = chimeraManager.getModelList();
151     // JAL-1728 newList.removeAll(oldList) does not work
152     for (ChimeraModel cm : newList)
153     {
154       if (cm.getModelName().equals(pe.getId()))
155       {
156         modelsToMap.add(cm);
157       }
158     }
159   }
160
161   /**
162    * Constructor
163    * 
164    * @param ssm
165    * @param pdbentry
166    * @param sequenceIs
167    * @param protocol
168    */
169   public JalviewChimeraBinding(StructureSelectionManager ssm,
170           PDBEntry[] pdbentry, SequenceI[][] sequenceIs,
171           DataSourceType protocol)
172   {
173     super(ssm, pdbentry, sequenceIs, protocol);
174     chimeraManager = new ChimeraManager(new StructureManager(true));
175     chimeraManager.setChimeraX(ViewerType.CHIMERAX.equals(getViewerType()));
176     setStructureCommands(new ChimeraCommands());
177   }
178
179   @Override
180   protected ViewerType getViewerType()
181   {
182     return ViewerType.CHIMERA;
183   }
184
185   /**
186    * Start a dedicated HttpServer to listen for Chimera notifications, and tell it
187    * to start listening
188    */
189   public void startChimeraListener()
190   {
191     try
192     {
193       chimeraListener = new ChimeraListener(this);
194       chimeraManager.startListening(chimeraListener.getUri());
195     } catch (BindException e)
196     {
197       System.err.println(
198               "Failed to start Chimera listener: " + e.getMessage());
199     }
200   }
201
202   /**
203    * Close down the Jalview viewer and listener, and (optionally) the associated
204    * Chimera window.
205    */
206   @Override
207   public void closeViewer(boolean closeChimera)
208   {
209     super.closeViewer(closeChimera);
210     if (this.chimeraListener != null)
211     {
212       chimeraListener.shutdown();
213       chimeraListener = null;
214     }
215     chimeraManager.clearOnChimeraExit();
216     chimeraManager = null;
217   }
218
219   /**
220    * Helper method to construct model spec in Chimera format:
221    * <ul>
222    * <li>#0 (#1 etc) for a PDB file with no sub-models</li>
223    * <li>#0.1 (#1.1 etc) for a PDB file with sub-models</li>
224    * <ul>
225    * Note for now we only ever choose the first of multiple models. This
226    * corresponds to the hard-coded Jmol equivalent (compare {1.1}). Refactor in
227    * future if there is a need to select specific sub-models.
228    * 
229    * @param pdbfnum
230    * @return
231    */
232   protected String getModelSpec(int pdbfnum)
233   {
234     if (pdbfnum < 0 || pdbfnum >= getPdbCount())
235     {
236       return "#" + pdbfnum; // temp hack for ChimeraX
237     }
238
239     /*
240      * For now, the test for having sub-models is whether multiple Chimera
241      * models are mapped for the PDB file; the models are returned as a response
242      * to the Chimera command 'list models type molecule', see
243      * ChimeraManager.getModelList().
244      */
245     List<ChimeraModel> maps = chimeraMaps.get(getStructureFiles()[pdbfnum]);
246     boolean hasSubModels = maps != null && maps.size() > 1;
247     return "#" + String.valueOf(pdbfnum) + (hasSubModels ? ".1" : "");
248   }
249
250   /**
251    * Launch Chimera, unless an instance linked to this object is already
252    * running. Returns true if Chimera is successfully launched, or already
253    * running, else false.
254    * 
255    * @return
256    */
257   public boolean launchChimera()
258   {
259     if (chimeraManager.isChimeraLaunched())
260     {
261       return true;
262     }
263
264     boolean launched = chimeraManager.launchChimera(getChimeraPaths());
265     if (launched)
266     {
267       startExternalViewerMonitor(chimeraManager.getChimeraProcess());
268     }
269     else
270     {
271       log("Failed to launch Chimera!");
272     }
273     return launched;
274   }
275
276   /**
277    * Returns a list of candidate paths to the Chimera program executable
278    * 
279    * @return
280    */
281   protected List<String> getChimeraPaths()
282   {
283     return StructureManager.getChimeraPaths(false);
284   }
285
286   /**
287    * Answers true if the Chimera process is still running, false if ended or not
288    * started.
289    * 
290    * @return
291    */
292   @Override
293   public boolean isViewerRunning()
294   {
295     return chimeraManager.isChimeraLaunched();
296   }
297
298   /**
299    * Send a command to Chimera, and optionally log and return any responses.
300    * 
301    * @param command
302    * @param getResponse
303    */
304   @Override
305   public List<String> executeCommand(final StructureCommandI command,
306           boolean getResponse)
307   {
308     if (chimeraManager == null || command == null)
309     {
310       // ? thread running after viewer shut down
311       return null;
312     }
313     List<String> reply = null;
314     // trim command or it may never find a match in the replyLog!!
315     String cmd = command.getCommand().trim();
316     List<String> lastReply = chimeraManager
317             .sendChimeraCommand(cmd, getResponse);
318     if (getResponse)
319     {
320       reply = lastReply;
321       Cache.log.debug(
322               "Response from command ('" + cmd + "') was:\n" + lastReply);
323     }
324
325     return reply;
326   }
327
328   @Override
329   public synchronized String[] getStructureFiles()
330   {
331     if (chimeraManager == null)
332     {
333       return new String[0];
334     }
335
336     return chimeraMaps.keySet()
337             .toArray(modelFileNames = new String[chimeraMaps.size()]);
338   }
339
340   /**
341    * Construct and send a command to highlight zero, one or more atoms. We do this
342    * by sending an "rlabel" command to show the residue label at that position.
343    */
344   @Override
345   public void highlightAtoms(List<AtomSpec> atoms)
346   {
347     if (atoms == null || atoms.size() == 0)
348     {
349       return;
350     }
351
352     boolean forChimeraX = chimeraManager.isChimeraX();
353     StringBuilder cmd = new StringBuilder(128);
354     boolean first = true;
355     boolean found = false;
356
357     for (AtomSpec atom : atoms)
358     {
359       int pdbResNum = atom.getPdbResNum();
360       String chain = atom.getChain();
361       String pdbfile = atom.getPdbFile();
362       List<ChimeraModel> cms = chimeraMaps.get(pdbfile);
363       if (cms != null && !cms.isEmpty())
364       {
365         if (first)
366         {
367           cmd.append(forChimeraX ? "label #" : "rlabel #");
368         }
369         else
370         {
371           cmd.append(",");
372         }
373         first = false;
374         if (forChimeraX)
375         {
376           cmd.append(cms.get(0).getModelNumber())
377                   .append("/").append(chain).append(":").append(pdbResNum);
378         }
379         else
380         {
381           cmd.append(cms.get(0).getModelNumber())
382                   .append(":").append(pdbResNum);
383           if (!chain.equals(" ") && !forChimeraX)
384           {
385             cmd.append(".").append(chain);
386           }
387         }
388         found = true;
389       }
390     }
391     String command = cmd.toString();
392
393     /*
394      * avoid repeated commands for the same residue
395      */
396     if (command.equals(lastHighlightCommand))
397     {
398       return;
399     }
400
401     /*
402      * unshow the label for the previous residue
403      */
404     if (lastHighlightCommand != null)
405     {
406       chimeraManager.sendChimeraCommand("~" + lastHighlightCommand, false);
407     }
408     if (found)
409     {
410       chimeraManager.sendChimeraCommand(command, false);
411     }
412     this.lastHighlightCommand = command;
413   }
414
415   /**
416    * Query Chimera for its current selection, and highlight it on the alignment
417    */
418   public void highlightChimeraSelection()
419   {
420     /*
421      * Ask Chimera for its current selection
422      */
423     List<String> selection = chimeraManager.getSelectedResidueSpecs();
424
425     /*
426      * Parse model number, residue and chain for each selected position,
427      * formatted as #0:123.A or #1.2:87.B (#model.submodel:residue.chain)
428      */
429     List<AtomSpec> atomSpecs = convertStructureResiduesToAlignment(
430             selection);
431
432     /*
433      * Broadcast the selection (which may be empty, if the user just cleared all
434      * selections)
435      */
436     getSsm().mouseOverStructure(atomSpecs);
437   }
438
439   /**
440    * Converts a list of Chimera atomspecs to a list of AtomSpec representing the
441    * corresponding residues (if any) in Jalview
442    * 
443    * @param structureSelection
444    * @return
445    */
446   protected List<AtomSpec> convertStructureResiduesToAlignment(
447           List<String> structureSelection)
448   {
449     boolean chimeraX = chimeraManager.isChimeraX();
450     List<AtomSpec> atomSpecs = new ArrayList<>();
451     for (String atomSpec : structureSelection)
452     {
453       try
454       {
455         AtomSpec spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
456         String pdbfilename = getPdbFileForModel(spec.getModelNumber());
457         spec.setPdbFile(pdbfilename);
458         atomSpecs.add(spec);
459       } catch (IllegalArgumentException e)
460       {
461         Cache.log.error("Failed to parse atomspec: " + atomSpec);
462       }
463     }
464     return atomSpecs;
465   }
466
467   /**
468    * @param modelId
469    * @return
470    */
471   protected String getPdbFileForModel(int modelId)
472   {
473     /*
474      * Work out the pdbfilename from the model number
475      */
476     String pdbfilename = modelFileNames[0];
477     findfileloop: for (String pdbfile : this.chimeraMaps.keySet())
478     {
479       for (ChimeraModel cm : chimeraMaps.get(pdbfile))
480       {
481         if (cm.getModelNumber() == modelId)
482         {
483           pdbfilename = pdbfile;
484           break findfileloop;
485         }
486       }
487     }
488     return pdbfilename;
489   }
490
491   private void log(String message)
492   {
493     System.err.println("## Chimera log: " + message);
494   }
495
496   /**
497    * Send a 'show' command for all atoms in the currently selected columns
498    * 
499    * TODO: pull up to abstract structure viewer interface
500    * 
501    * @param vp
502    */
503   public void highlightSelection(AlignmentViewPanel vp)
504   {
505     List<Integer> cols = vp.getAlignViewport().getColumnSelection()
506             .getSelected();
507     AlignmentI alignment = vp.getAlignment();
508     StructureSelectionManager sm = getSsm();
509     for (SequenceI seq : alignment.getSequences())
510     {
511       /*
512        * convert selected columns into sequence positions
513        */
514       int[] positions = new int[cols.size()];
515       int i = 0;
516       for (Integer col : cols)
517       {
518         positions[i++] = seq.findPosition(col);
519       }
520       sm.highlightStructure(this, seq, positions);
521     }
522   }
523
524   /**
525    * Constructs and send commands to Chimera to set attributes on residues for
526    * features visible in Jalview.
527    * <p>
528    * The syntax is: setattr r &lt;attName&gt; &lt;attValue&gt; &lt;atomSpec&gt;
529    * <p>
530    * For example: setattr r jv_chain "Ferredoxin-1, Chloroplastic" #0:94.A
531    * 
532    * @param avp
533    * @return
534    */
535   public int sendFeaturesToViewer(AlignmentViewPanel avp)
536   {
537     // TODO refactor as required to pull up to an interface
538
539     Map<String, Map<Object, AtomSpecModel>> featureValues = buildFeaturesMap(
540             avp);
541     List<StructureCommandI> commands = getCommandGenerator()
542             .setAttributes(featureValues);
543     if (commands.size() > 10)
544     {
545       sendCommandsByFile(commands);
546     }
547     else
548     {
549       for (StructureCommandI command : commands)
550       {
551         sendAsynchronousCommand(command, null);
552       }
553     }
554     return commands.size();
555   }
556
557   /**
558    * Write commands to a temporary file, and send a command to Chimera to open the
559    * file as a commands script. For use when sending a large number of separate
560    * commands would overload the REST interface mechanism.
561    * 
562    * @param commands
563    */
564   protected void sendCommandsByFile(List<StructureCommandI> commands)
565   {
566     try
567     {
568       File tmp = File.createTempFile("chim", getCommandFileExtension());
569       tmp.deleteOnExit();
570       PrintWriter out = new PrintWriter(new FileOutputStream(tmp));
571       for (StructureCommandI command : commands)
572       {
573         out.println(command.getCommand());
574       }
575       out.flush();
576       out.close();
577       String path = tmp.getAbsolutePath();
578       StructureCommandI command = getCommandGenerator()
579               .openCommandFile(path);
580       sendAsynchronousCommand(command, null);
581     } catch (IOException e)
582     {
583       System.err.println("Sending commands to Chimera via file failed with "
584               + e.getMessage());
585     }
586   }
587
588   /**
589    * Returns the file extension required for a file of commands to be read by
590    * the structure viewer
591    * @return
592    */
593   protected String getCommandFileExtension()
594   {
595     return ".com";
596   }
597
598   /**
599    * Get Chimera residues which have the named attribute, find the mapped
600    * positions in the Jalview sequence(s), and set as sequence features
601    * 
602    * @param attName
603    * @param alignmentPanel
604    */
605   public void copyStructureAttributesToFeatures(String attName,
606           AlignmentViewPanel alignmentPanel)
607   {
608     // todo pull up to AAStructureBindingModel (and interface?)
609
610     /*
611      * ask Chimera to list residues with the attribute, reporting its value
612      */
613     // this alternative command
614     // list residues spec ':*/attName' attr attName
615     // doesn't report 'None' values (which is good), but
616     // fails for 'average.bfactor' (which is bad):
617
618     String cmd = "list residues attr '" + attName + "'";
619     List<String> residues = executeCommand(new StructureCommand(cmd), true);
620
621     boolean featureAdded = createFeaturesForAttributes(attName, residues);
622     if (featureAdded)
623     {
624       alignmentPanel.getFeatureRenderer().featuresAdded();
625     }
626   }
627
628   /**
629    * Create features in Jalview for the given attribute name and structure
630    * residues.
631    * 
632    * <pre>
633    * The residue list should be 0, 1 or more reply lines of the format: 
634    *     residue id #0:5.A isHelix -155.000836316 index 5 
635    * or 
636    *     residue id #0:6.A isHelix None
637    * </pre>
638    * 
639    * @param attName
640    * @param residues
641    * @return
642    */
643   protected boolean createFeaturesForAttributes(String attName,
644           List<String> residues)
645   {
646     boolean featureAdded = false;
647     String featureGroup = getViewerFeatureGroup();
648     boolean chimeraX = chimeraManager.isChimeraX();
649
650     for (String residue : residues)
651     {
652       AtomSpec spec = null;
653       String[] tokens = residue.split(" ");
654       if (tokens.length < 5)
655       {
656         continue;
657       }
658       String atomSpec = tokens[2];
659       String attValue = tokens[4];
660
661       /*
662        * ignore 'None' (e.g. for phi) or 'False' (e.g. for isHelix)
663        */
664       if ("None".equalsIgnoreCase(attValue)
665               || "False".equalsIgnoreCase(attValue))
666       {
667         continue;
668       }
669
670       try
671       {
672         spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
673       } catch (IllegalArgumentException e)
674       {
675         System.err.println("Problem parsing atomspec " + atomSpec);
676         continue;
677       }
678
679       String chainId = spec.getChain();
680       String description = attValue;
681       float score = Float.NaN;
682       try
683       {
684         score = Float.valueOf(attValue);
685         description = chainId;
686       } catch (NumberFormatException e)
687       {
688         // was not a float value
689       }
690
691       String pdbFile = getPdbFileForModel(spec.getModelNumber());
692       spec.setPdbFile(pdbFile);
693
694       List<AtomSpec> atoms = Collections.singletonList(spec);
695
696       /*
697        * locate the mapped position in the alignment (if any)
698        */
699       SearchResultsI sr = getSsm()
700               .findAlignmentPositionsForStructurePositions(atoms);
701
702       /*
703        * expect one matched alignment position, or none 
704        * (if the structure position is not mapped)
705        */
706       for (SearchResultMatchI m : sr.getResults())
707       {
708         SequenceI seq = m.getSequence();
709         int start = m.getStart();
710         int end = m.getEnd();
711         SequenceFeature sf = new SequenceFeature(attName, description,
712                 start, end, score, featureGroup);
713         // todo: should SequenceFeature have an explicit property for chain?
714         // note: repeating the action shouldn't duplicate features
715         featureAdded |= seq.addSequenceFeature(sf);
716       }
717     }
718     return featureAdded;
719   }
720
721   /**
722    * Answers the feature group name to apply to features created in Jalview from
723    * Chimera attributes
724    * 
725    * @return
726    */
727   protected String getViewerFeatureGroup()
728   {
729     // todo pull up to interface
730     return CHIMERA_FEATURE_GROUP;
731   }
732
733   @Override
734   public String getModelIdForFile(String pdbFile)
735   {
736     List<ChimeraModel> foundModels = chimeraMaps.get(pdbFile);
737     if (foundModels != null && !foundModels.isEmpty())
738     {
739       return String.valueOf(foundModels.get(0).getModelNumber());
740     }
741     return "";
742   }
743
744   /**
745    * Answers a (possibly empty) list of attribute names in Chimera[X], excluding
746    * any which were added from Jalview
747    * 
748    * @return
749    */
750   public List<String> getChimeraAttributes()
751   {
752     List<String> atts = chimeraManager.getAttrList();
753     Iterator<String> it = atts.iterator();
754     while (it.hasNext())
755     {
756       if (it.next().startsWith(ChimeraCommands.NAMESPACE_PREFIX))
757       {
758         /*
759          * attribute added from Jalview - exclude it
760          */
761         it.remove();
762       }
763     }
764     return atts;
765   }
766
767   /**
768    * Returns the file extension to use for a saved viewer session file (.py)
769    * 
770    * @return
771    */
772   @Override
773   public String getSessionFileExtension()
774   {
775     return CHIMERA_SESSION_EXTENSION;
776   }
777
778   @Override
779   public String getHelpURL()
780   {
781     return "https://www.cgl.ucsf.edu/chimera/docs/UsersGuide";
782   }
783 }