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