JAL-3878 Display results of completed alignment job
[jalview.git] / src / jalview / ws2 / operations / AlignmentOperation.java
1 package jalview.ws2.operations;
2
3 import static java.lang.String.format;
4
5 import java.awt.event.MouseAdapter;
6 import java.awt.event.MouseEvent;
7 import java.io.IOException;
8 import java.util.ArrayList;
9 import java.util.Collections;
10 import java.util.HashMap;
11 import java.util.Hashtable;
12 import java.util.LinkedHashMap;
13 import java.util.List;
14 import java.util.Map;
15 import java.util.Objects;
16 import java.util.concurrent.CompletionStage;
17
18 import javax.swing.JMenu;
19 import javax.swing.JMenuItem;
20 import javax.swing.ToolTipManager;
21
22 import jalview.analysis.AlignSeq;
23 import jalview.analysis.AlignmentSorter;
24 import jalview.analysis.SeqsetUtils;
25 import jalview.bin.Cache;
26 import jalview.datamodel.AlignedCodonFrame;
27 import jalview.datamodel.Alignment;
28 import jalview.datamodel.AlignmentI;
29 import jalview.datamodel.AlignmentOrder;
30 import jalview.datamodel.AlignmentView;
31 import jalview.datamodel.HiddenColumns;
32 import jalview.datamodel.SequenceI;
33 import jalview.datamodel.Sequence;
34 import jalview.gui.AlignFrame;
35 import jalview.gui.AlignViewport;
36 import jalview.gui.JvSwingUtils;
37 import jalview.gui.WebserviceInfo;
38 import jalview.gui.WsJobParameters;
39 import jalview.util.MathUtils;
40 import jalview.util.MessageManager;
41 import jalview.ws.params.ArgumentI;
42 import jalview.ws.params.WsParamSetI;
43 import jalview.ws2.MenuEntryProviderI;
44 import jalview.ws2.ResultSupplier;
45 import jalview.ws2.WSJob;
46 import jalview.ws2.WSJobStatus;
47 import jalview.ws2.WebServiceExecutor;
48 import jalview.ws2.WebServiceI;
49 import jalview.ws2.WebServiceInfoUpdater;
50 import jalview.ws2.WebServiceWorkerI;
51 import jalview.ws2.utils.WSJobList;
52
53 /**
54  *
55  * @author mmwarowny
56  *
57  */
58 public class AlignmentOperation implements Operation
59 {
60   final WebServiceI service;
61
62   final ResultSupplier<AlignmentI> supplier;
63
64   public AlignmentOperation(WebServiceI service,
65           ResultSupplier<AlignmentI> supplier)
66   {
67     this.service = service;
68     this.supplier = supplier;
69   }
70
71   @Override
72   public int getMinSequences()
73   {
74     return 2;
75   }
76
77   @Override
78   public int getMaxSequences()
79   {
80     return Integer.MAX_VALUE;
81   }
82
83   @Override
84   public boolean isProteinOperation()
85   {
86     return true;
87   }
88
89   @Override
90   public boolean isNucleotideOperation()
91   {
92     return true;
93   }
94
95   @Override
96   public boolean canSubmitGaps()
97   {
98     // hack copied from original jabaws code, don't blame me
99     return service.getName().contains("lustal");
100   }
101
102   @Override
103   public MenuEntryProviderI getMenuBuilder()
104   {
105     return this::buildMenu;
106   }
107
108   protected void buildMenu(JMenu parent, AlignFrame frame)
109   {
110     if (canSubmitGaps())
111     {
112       var alignSubmenu = new JMenu(service.getName());
113       buildMenu(alignSubmenu, frame, false);
114       parent.add(alignSubmenu);
115       var realignSubmenu = new JMenu(MessageManager.formatMessage(
116               "label.realign_with_params", service.getName()));
117       realignSubmenu.setToolTipText(MessageManager
118               .getString("label.align_sequences_to_existing_alignment"));
119       buildMenu(realignSubmenu, frame, true);
120       parent.add(realignSubmenu);
121     }
122     else
123     {
124       buildMenu(parent, frame, false);
125     }
126   }
127
128   protected void buildMenu(JMenu parent, AlignFrame frame,
129           boolean submitGaps)
130   {
131     final String action = submitGaps ? "Align" : "Realign";
132     final var calcName = service.getName();
133
134     final AlignmentView msa = frame.gatherSequencesForAlignment();
135     final AlignViewport viewport = frame.getViewport();
136     final AlignmentI alignment = frame.getViewport().getAlignment();
137     String title = frame.getTitle();
138     WebServiceExecutor executor = frame.getViewport().getWSExecutor();
139     {
140       var item = new JMenuItem(MessageManager.formatMessage(
141               "label.calcname_with_default_settings", calcName));
142       item.setToolTipText(MessageManager
143               .formatMessage("label.action_with_default_settings", action));
144       item.addActionListener((event) -> {
145         if (msa != null)
146         {
147           WebServiceWorkerI worker = new AlignmentWorker(msa,
148                   Collections.emptyList(), title, submitGaps, true,
149                   alignment, viewport);
150           executor.submit(worker);
151         }
152       });
153       parent.add(item);
154     }
155
156     if (service.hasParameters())
157     {
158       var item = new JMenuItem(
159               MessageManager.getString("label.edit_settings_and_run"));
160       item.setToolTipText(MessageManager.getString(
161               "label.view_and_change_parameters_before_alignment"));
162       item.addActionListener((event) -> {
163         if (msa != null)
164         {
165           openEditParamsDialog(service, null, null)
166                   .thenAcceptAsync((arguments) -> {
167                     if (arguments != null)
168                     {
169                       WebServiceWorkerI worker = new AlignmentWorker(msa,
170                               arguments, title, submitGaps, true, alignment,
171                               viewport);
172                       executor.submit(worker);
173                     }
174                   });
175         }
176       });
177       parent.add(item);
178     }
179
180     var presets = service.getParamStore().getPresets();
181     if (presets != null && presets.size() > 0)
182     {
183       final var presetList = new JMenu(MessageManager
184               .formatMessage("label.run_with_preset_params", calcName));
185       final var showToolTipFor = ToolTipManager.sharedInstance()
186               .getDismissDelay();
187       for (final var preset : presets)
188       {
189         var item = new JMenuItem(preset.getName());
190         final int QUICK_TOOLTIP = 1500;
191         item.addMouseListener(new MouseAdapter()
192         {
193           @Override
194           public void mouseEntered(MouseEvent e)
195           {
196             ToolTipManager.sharedInstance().setDismissDelay(QUICK_TOOLTIP);
197           }
198
199           @Override
200           public void mouseExited(MouseEvent e)
201           {
202             ToolTipManager.sharedInstance().setDismissDelay(showToolTipFor);
203           }
204         });
205         String tooltip = JvSwingUtils.wrapTooltip(true,
206                 format("<strong>%s</strong><br/>%s",
207                         MessageManager.getString(
208                                 preset.isModifiable() ? "label.user_preset"
209                                         : "label.service_preset"),
210                         preset.getDescription()));
211         item.setToolTipText(tooltip);
212         item.addActionListener((event) -> {
213           if (msa != null)
214           {
215             WebServiceWorkerI worker = new AlignmentWorker(msa,
216                     preset.getArguments(), title, submitGaps, true,
217                     alignment, viewport);
218             executor.submit(worker);
219           }
220         });
221         presetList.add(item);
222       }
223       parent.add(presetList);
224     }
225   }
226
227   private CompletionStage<List<ArgumentI>> openEditParamsDialog(
228           WebServiceI service, WsParamSetI preset,
229           List<ArgumentI> arguments)
230   {
231     WsJobParameters jobParams;
232     if (preset == null && arguments != null && arguments.size() > 0)
233       jobParams = new WsJobParameters(service.getParamStore(), preset,
234               arguments);
235     else
236       jobParams = new WsJobParameters(service.getParamStore(), preset,
237               null);
238     var stage = jobParams.showRunDialog();
239     return stage.thenApply((startJob) -> {
240       if (startJob)
241       {
242         if (jobParams.getPreset() == null)
243         {
244           return jobParams.getJobParams();
245         }
246         else
247         {
248           return jobParams.getPreset().getArguments();
249         }
250       }
251       else
252       {
253         return null;
254       }
255     });
256   }
257
258   /**
259    * Implementation of the web service worker performing multiple sequence
260    * alignment.
261    *
262    * @author mmwarowny
263    *
264    */
265   private class AlignmentWorker implements WebServiceWorkerI
266   {
267
268     private long uid = MathUtils.getUID();
269
270     private final AlignmentView msa;
271
272     private final AlignmentI dataset;
273
274     private final List<AlignedCodonFrame> codonFrame = new ArrayList<>();
275
276     private List<ArgumentI> args = Collections.emptyList();
277
278     private String alnTitle = "";
279
280     private boolean submitGaps = false;
281
282     private boolean preserveOrder = false;
283
284     private char gapCharacter;
285
286     private WSJobList jobs = new WSJobList();
287
288     private Map<Long, JobInput> inputs = new LinkedHashMap<>();
289
290     private WebserviceInfo wsInfo;
291
292     private Map<Long, Integer> exceptionCount = new HashMap<>();
293
294     private final int MAX_RETRY = 5;
295
296     AlignmentWorker(AlignmentView msa, List<ArgumentI> args,
297             String alnTitle, boolean submitGaps, boolean preserveOrder,
298             AlignmentI alignment, AlignViewport viewport)
299     {
300       this.msa = msa;
301       this.dataset = alignment.getDataset();
302       List<AlignedCodonFrame> cf = Objects.requireNonNullElse(
303               alignment.getCodonFrames(), Collections.emptyList());
304       this.codonFrame.addAll(cf);
305       this.args = args;
306       this.alnTitle = alnTitle;
307       this.submitGaps = submitGaps;
308       this.preserveOrder = preserveOrder;
309       this.gapCharacter = viewport.getGapCharacter();
310
311       String panelInfo = String.format("%s using service hosted at %s%n%s",
312               service.getName(), service.getHostName(),
313               Objects.requireNonNullElse(service.getDescription(), ""));
314       wsInfo = new WebserviceInfo(service.getName(), panelInfo, false);
315     }
316
317     @Override
318     public long getUID()
319     {
320       return uid;
321     }
322
323     @Override
324     public WebServiceI getWebService()
325     {
326       return service;
327     }
328
329     @Override
330     public List<WSJob> getJobs()
331     {
332       return Collections.unmodifiableList(jobs);
333     }
334
335     @Override
336     public void startJobs() throws IOException
337     {
338       String outputHeader = String.format("%s of %s%nJob details%n",
339               submitGaps ? "Re-alignment" : "Alignment", alnTitle);
340       SequenceI[][] conmsa = msa.getVisibleContigs('-');
341       if (conmsa == null)
342       {
343         return;
344       }
345       WebServiceInfoUpdater updater = new WebServiceInfoUpdater(wsInfo);
346       updater.setOutputHeader(outputHeader);
347       int numValid = 0;
348       for (int i = 0; i < conmsa.length; i++)
349       {
350         JobInput input = JobInput.create(conmsa[i], 2, submitGaps);
351         WSJob job = new WSJob(service.getProviderName(), service.getName(),
352                 service.getHostName());
353         job.setJobNum(wsInfo.addJobPane());
354         if (conmsa.length > 0)
355         {
356           wsInfo.setProgressName(String.format("region %d", i),
357                   job.getJobNum());
358         }
359         wsInfo.setProgressText(job.getJobNum(), outputHeader);
360         job.addPropertyChangeListener(updater);
361         inputs.put(job.getUid(), input);
362         jobs.add(job);
363         if (input.isInputValid())
364         {
365           int count;
366           String jobId = null;
367           do
368           {
369             count = exceptionCount.getOrDefault(job.getUid(), MAX_RETRY);
370             try
371             {
372               jobId = service.submit(input.inputSequences, args);
373               Cache.log.debug((format("Job %s submitted", job)));
374               exceptionCount.remove(job.getUid());
375             } catch (IOException e)
376             {
377               exceptionCount.put(job.getUid(), --count);
378             }
379           } while (jobId == null && count > 0);
380           if (jobId != null)
381           {
382             job.setJobId(jobId);
383             job.setStatus(WSJobStatus.SUBMITTED);
384             numValid++;
385           }
386           else
387           {
388             job.setStatus(WSJobStatus.SERVER_ERROR);
389           }
390         }
391         else
392         {
393           job.setStatus(WSJobStatus.INVALID);
394           job.setErrorLog(
395                   MessageManager.getString("label.empty_alignment_job"));
396         }
397       }
398       if (numValid > 0)
399       {
400         // wsInfo.setThisService() should happen here
401         wsInfo.setVisible(true);
402       }
403       else
404       {
405         wsInfo.setVisible(false);
406         // TODO show notification dialog.
407         // JvOptionPane.showMessageDialog(frame,
408         // MessageManager.getString("info.invalid_msa_input_mininfo"),
409         // MessageManager.getString("info.invalid_msa_notenough"),
410         // JvOptionPane.INFORMATION_MESSAGE);
411       }
412     }
413
414     @Override
415     public boolean pollJobs()
416     {
417       boolean done = true;
418       for (WSJob job : getJobs())
419       {
420         if (!job.getStatus().isDone())
421         {
422           Cache.log.debug(format("Polling job %s.", job));
423           try
424           {
425             service.updateProgress(job);
426             exceptionCount.remove(job.getUid());
427           } catch (IOException e)
428           {
429             Cache.log.error(format("Polling job %s failed.", job), e);
430             wsInfo.appendProgressText(job.getJobNum(),
431                     MessageManager.formatMessage("info.server_exception",
432                             service.getName(), e.getMessage()));
433             int count = exceptionCount.getOrDefault(job.getUid(),
434                     MAX_RETRY);
435             if (--count <= 0)
436             {
437               job.setStatus(WSJobStatus.SERVER_ERROR);
438               Cache.log.warn(format(
439                       "Attempts limit exceeded. Droping job %s.", job));
440             }
441             exceptionCount.put(job.getUid(), count);
442           } catch (OutOfMemoryError e)
443           {
444             job.setStatus(WSJobStatus.BROKEN);
445             Cache.log.error(
446                     format("Out of memory when retrieving job %s", job), e);
447           }
448           Cache.log.debug(
449                   format("Job %s status is %s", job, job.getStatus()));
450         }
451         done &= job.getStatus().isDone();
452       }
453       updateWSInfoGlobalStatus();
454       return done;
455     }
456
457     private void updateWSInfoGlobalStatus()
458     {
459       if (jobs.countRunning() > 0)
460       {
461         wsInfo.setStatus(WebserviceInfo.STATE_RUNNING);
462       }
463       else if (jobs.countQueuing() > 0
464               || jobs.countSubmitted() < jobs.size())
465       {
466         wsInfo.setStatus(WebserviceInfo.STATE_QUEUING);
467       }
468       else
469       {
470         if (jobs.countSuccessful() > 0)
471         {
472           wsInfo.setStatus(WebserviceInfo.STATE_STOPPED_OK);
473         }
474         else if (jobs.countCancelled() > 0)
475         {
476           wsInfo.setStatus(WebserviceInfo.STATE_CANCELLED_OK);
477         }
478         else if (jobs.countFailed() > 0)
479         {
480           wsInfo.setStatus(WebserviceInfo.STATE_STOPPED_ERROR);
481         }
482       }
483     }
484
485     @Override
486     public void done()
487     {
488       long progbarId = MathUtils.getUID();
489       wsInfo.setProgressBar(
490               MessageManager.getString("status.collecting_job_results"),
491               progbarId);
492       Map<Long, AlignmentI> results = new LinkedHashMap<>();
493       for (WSJob job : getJobs())
494       {
495         try
496         {
497           AlignmentI alignment = supplier.getResult(job);
498           if (alignment != null)
499           {
500             results.put(job.getUid(), alignment);
501           }
502         } catch (Exception e)
503         {
504           if (!service.handleCollectionError(job, e))
505           {
506             Cache.log.error("Couldn't get alignment for job.", e);
507             // TODO: Increment exception count and retry.
508             job.setStatus(WSJobStatus.SERVER_ERROR);
509           }
510         }
511       }
512       updateWSInfoGlobalStatus();
513       if (results.size() > 0)
514       {
515         wsInfo.showResultsNewFrame
516                 .addActionListener(evt -> displayResults(results));
517         wsInfo.setResultsReady();
518       }
519       else
520       {
521         wsInfo.setFinishedNoResults();
522       }
523       wsInfo.removeProgressBar(progbarId);
524     }
525
526     private void displayResults(Map<Long, AlignmentI> alignments)
527     {
528       List<AlignmentOrder> alorders = new ArrayList<>();
529       SequenceI[][] results = new SequenceI[jobs.size()][];
530       AlignmentOrder[] orders = new AlignmentOrder[jobs.size()];
531       for (int i = 0; i < jobs.size(); i++)
532       {
533         WSJob job = jobs.get(i);
534         AlignmentI aln = alignments.get(job.getUid());
535         if (aln != null)
536         { // equivalent of job.hasResults()
537           /* Get the alignment including any empty sequences in the original
538            * order with original ids. */
539           JobInput input = inputs.get(job.getUid());
540           char gapChar = aln.getGapCharacter();
541           List<SequenceI> emptySeqs = input.emptySequences;
542           List<SequenceI> alnSeqs = aln.getSequences();
543           // find the width of the longest sequence
544           int width = 0;
545           for (var seq : alnSeqs)
546             width = Integer.max(width, seq.getLength());
547           for (var emptySeq : emptySeqs)
548             width = Integer.max(width, emptySeq.getLength());
549           // pad shorter sequences with gaps
550           String gapSeq = String.join("",
551                   Collections.nCopies(width, Character.toString(gapChar)));
552           List<SequenceI> seqs = new ArrayList<>(
553                   alnSeqs.size() + emptySeqs.size());
554           seqs.addAll(alnSeqs);
555           seqs.addAll(emptySeqs);
556           for (var seq : seqs)
557           {
558             if (seq.getLength() < width)
559               seq.setSequence(seq.getSequenceAsString()
560                       + gapSeq.substring(seq.getLength()));
561           }
562           SequenceI[] result = seqs.toArray(new SequenceI[0]);
563           AlignmentOrder msaOrder = new AlignmentOrder(result);
564           AlignmentSorter.recoverOrder(result);
565           // temporary workaround for deuniquify
566           @SuppressWarnings({ "rawtypes", "unchecked" })
567           Hashtable names = new Hashtable(input.sequenceNames);
568           SeqsetUtils.deuniquify(names, result);
569           alorders.add(msaOrder);
570           results[i] = result;
571           orders[i] = msaOrder;
572         }
573         else
574         {
575           results[i] = null;
576         }
577       }
578
579       Object[] newView = msa.getUpdatedView(results, orders, gapCharacter);
580       // free references to original data
581       for (int i = 0; i < jobs.size(); i++)
582       {
583         results[i] = null;
584         orders[i] = null;
585       }
586       SequenceI[] alignment = (SequenceI[]) newView[0];
587       HiddenColumns hidden = (HiddenColumns) newView[1];
588       Alignment aln = new Alignment(alignment);
589       aln.setProperty("Alignment Program", service.getName());
590       if (dataset != null)
591         aln.setDataset(dataset);
592
593       propagateDatasetMappings(aln);
594
595       displayNewFrame(aln, alorders, hidden);
596     }
597
598     /* 
599      * conserves dataset references to sequence objects returned from web
600      * services. propagate codon frame data to alignment. 
601      */
602     private void propagateDatasetMappings(Alignment aln)
603     {
604       if (codonFrame != null)
605       {
606         SequenceI[] alignment = aln.getSequencesArray();
607         for (SequenceI seq : alignment)
608         {
609           for (AlignedCodonFrame acf : codonFrame)
610           {
611             if (acf != null && acf.involvesSequence(seq))
612             {
613               aln.addCodonFrame(acf);
614               break;
615             }
616           }
617         }
618       }
619     }
620
621     private void displayNewFrame(AlignmentI aln,
622             List<AlignmentOrder> alorders, HiddenColumns hidden)
623     {
624       AlignFrame frame = new AlignFrame(aln, hidden,
625               AlignFrame.DEFAULT_WIDTH, AlignFrame.DEFAULT_HEIGHT);
626       // TODO store feature renderer settings in worker object
627       // frame.getFeatureRenderer().transferSettings(featureSettings);
628       var regions = sortOrders(alorders);
629       if (alorders.size() == 1)
630       {
631         frame.addSortByOrderMenuItem(
632                 format("%s Ordering", service.getName()), alorders.get(0));
633       }
634       else
635       {
636         for (int i = 0; i < alorders.size(); i++)
637         {
638           final int j = i;
639           Iterable<String> iter = () -> regions.get(j).stream()
640                   .map(it -> Integer.toString(it)).iterator();
641           var orderName = format("%s Region %s Ordering", service.getName(),
642                   String.join(",", iter));
643           frame.addSortByOrderMenuItem(orderName, alorders.get(i));
644         }
645       }
646
647       /* TODO
648        * If alignment was requested from one half of a SplitFrame, show in a
649        * SplitFrame with the other pane similarly aligned.
650        */
651     }
652
653     private List<List<Integer>> sortOrders(List<?> alorders)
654     {
655       List<List<Integer>> regions = new ArrayList<>();
656       for (int i = 0; i < alorders.size(); i++)
657       {
658         List<Integer> regs = new ArrayList<>();
659         regs.add(i);
660         int j = i + 1;
661         while (j < alorders.size())
662         {
663           if (alorders.get(i).equals(alorders.get(j)))
664           {
665             alorders.remove(j);
666             regs.add(j);
667           }
668           else
669           {
670             j++;
671           }
672         }
673         regions.add(regs);
674       }
675       return regions;
676     }
677   }
678
679   private static class JobInput
680   {
681     final List<SequenceI> inputSequences;
682
683     final List<SequenceI> emptySequences;
684
685     @SuppressWarnings("rawtypes")
686     final Map<String, ? extends Map> sequenceNames;
687
688     private JobInput(int numSequences, List<SequenceI> inputSequences,
689             List<SequenceI> emptySequences,
690             @SuppressWarnings("rawtypes") Map<String, ? extends Map> names)
691     {
692       this.inputSequences = Collections.unmodifiableList(inputSequences);
693       this.emptySequences = Collections.unmodifiableList(emptySequences);
694       this.sequenceNames = names;
695     }
696
697     boolean isInputValid()
698     {
699       return inputSequences.size() >= 2;
700     }
701
702     static JobInput create(SequenceI[] sequences, int minLength,
703             boolean submitGaps)
704     {
705       assert minLength >= 0 : MessageManager.getString(
706               "error.implementation_error_minlen_must_be_greater_zero");
707       int numSeq = 0;
708       for (SequenceI seq : sequences)
709       {
710         if (seq.getEnd() - seq.getStart() >= minLength)
711         {
712           numSeq++;
713         }
714       }
715
716       List<SequenceI> inputSequences = new ArrayList<>();
717       List<SequenceI> emptySequences = new ArrayList<>();
718       @SuppressWarnings("rawtypes")
719       Map<String, Hashtable> names = new LinkedHashMap<>();
720       for (int i = 0; i < sequences.length; i++)
721       {
722         SequenceI seq = sequences[i];
723         String newName = SeqsetUtils.unique_name(i);
724         @SuppressWarnings("rawtypes")
725         Hashtable hash = SeqsetUtils.SeqCharacterHash(seq);
726         names.put(newName, hash);
727         if (numSeq > 1 && seq.getEnd() - seq.getStart() >= minLength)
728         {
729           String seqString = seq.getSequenceAsString();
730           if (!submitGaps)
731           {
732             seqString = AlignSeq.extractGaps(
733                     jalview.util.Comparison.GapChars, seqString);
734           }
735           inputSequences.add(new Sequence(newName, seqString));
736         }
737         else
738         {
739           String seqString = null;
740           if (seq.getEnd() >= seq.getStart()) // is it ever false?
741           {
742             seqString = seq.getSequenceAsString();
743             if (!submitGaps)
744             {
745               seqString = AlignSeq.extractGaps(
746                       jalview.util.Comparison.GapChars, seqString);
747             }
748           }
749           emptySequences.add(new Sequence(newName, seqString));
750         }
751       }
752
753       return new JobInput(numSeq, inputSequences, emptySequences, names);
754     }
755   }
756
757 }