in progress...
[jalview.git] / forester / ruby / evoruby / lib / evo / msa / msa.rb
1 #
2 # = lib/evo/msa/msa.rb - Msa class
3 #
4 # Copyright::    Copyright (C) 2017 Christian M. Zmasek
5 # License::      GNU Lesser General Public License (LGPL)
6 #
7 # Last modified: 2017/03/13
8
9 require 'lib/evo/util/constants'
10 require 'lib/evo/util/util'
11 require 'lib/evo/sequence/sequence'
12
13 module Evoruby
14   class Msa
15     def initialize()
16       @sequences = Array.new
17       @identical_seqs_detected = Array.new
18       @name_to_seq_indices = Hash.new
19       @namestart_to_seq_indices = Hash.new
20       @name = nil
21     end
22
23     def get_name
24       @name
25     end
26
27     def set_name( name )
28       @name = name
29     end
30
31     def add_sequence( sequence )
32       @sequences.push( sequence )
33     end
34
35     def add( name, molecular_sequence_str )
36       add_sequence( Sequence.new( name, molecular_sequence_str ) )
37     end
38
39     def get_sequence( index )
40       if ( index < 0 || index > get_number_of_seqs() - 1 )
41         error_msg = "attempt to get sequence " <<
42         index.to_s << " in alignment of " << get_number_of_seqs().to_s <<
43         " sequences"
44         raise ArgumentError, error_msg
45       end
46       return @sequences[ index ]
47     end
48
49     def remove_sequence!( index )
50       if ( index < 0 || index > get_number_of_seqs() - 1 )
51         error_msg = "attempt to remove sequence " <<
52         index.to_s << " in alignment of " << get_number_of_seqs().to_s <<
53         " sequences"
54         raise ArgumentError, error_msg
55       end
56       @name_to_seq_indices.clear
57       @namestart_to_seq_indices.clear
58       @sequences.delete_at( index )
59     end
60
61     def get_identical_seqs_detected
62       @identical_seqs_detected
63     end
64
65     def is_aligned()
66       if ( get_number_of_seqs < 1 )
67         return false
68       else
69         l = @sequences[ 0 ].get_length()
70         for i in 0 ... get_number_of_seqs()
71           if ( get_sequence( i ).get_length() != l )
72             return false
73           end
74         end
75       end
76       return true
77     end
78
79     def find_by_name( name, case_sensitive, partial_match )
80       if case_sensitive && !partial_match && @name_to_seq_indices.has_key?( name )
81         return @name_to_seq_indices[ name ]
82       end
83       indices = Array.new()
84       for i in 0 ... get_number_of_seqs()
85         current_name = get_sequence( i ).get_name()
86         if case_sensitive && !partial_match
87           if !@name_to_seq_indices.has_key?( current_name )
88             @name_to_seq_indices[ current_name ] = []
89           end
90           @name_to_seq_indices[ current_name ].push( i )
91         end
92         if !case_sensitive
93           current_name = current_name.downcase
94           name = name.downcase
95         end
96         if current_name == name ||
97         ( partial_match && current_name.include?( name ) )
98           indices.push( i )
99         end
100       end
101       indices
102     end
103
104     def find_by_name_pattern( name_re, avoid_similar_to = true )
105       indices = []
106       for i in 0 ... get_number_of_seqs()
107         if avoid_similar_to
108           m = name_re.match( get_sequence( i ).get_name() )
109           if m && !m.pre_match.downcase.include?( "similar to " )
110             indices.push( i )
111           end
112         else
113           if name_re.match( get_sequence( i ).get_name() )
114             indices.push( i )
115           end
116         end
117       end
118       indices
119     end
120
121     # throws ArgumentError
122     def get_by_name_pattern( name_re , avoid_similar_to = true )
123       indices = find_by_name_pattern( name_re, avoid_similar_to  )
124       if ( indices.length > 1 )
125         error_msg = "pattern  " + name_re.to_s + " not unique"
126         raise ArgumentError, error_msg
127       elsif ( indices.length < 1 )
128         error_msg = "pattern " + name_re.to_s + " not found"
129         raise ArgumentError, error_msg
130       end
131       get_sequence( indices[ 0 ] )
132     end
133
134     def find_by_name_start(name, case_sensitive = true)
135       if case_sensitive && @namestart_to_seq_indices.has_key?( name )
136         return @namestart_to_seq_indices[ name ]
137       end
138       indices = []
139       for i in 0 ... get_number_of_seqs
140         get_sequence(i).get_name =~ /^\s*(\S+)/
141         current_name = $1
142         if case_sensitive
143           unless @namestart_to_seq_indices.has_key?(current_name)
144             @namestart_to_seq_indices[current_name] = []
145           end
146           @namestart_to_seq_indices[current_name].push( i )
147         end
148         if !case_sensitive
149           current_name = current_name.downcase
150           name = name.downcase
151         end
152         if current_name == name
153           indices.push(i)
154         end
155       end
156       indices
157     end
158
159     def has?( name, case_sensitive = true, partial_match = false )
160       for i in 0 ... get_number_of_seqs()
161         current_name = get_sequence( i ).get_name()
162         if !case_sensitive
163           current_name = current_name.downcase
164           name = name.downcase
165         end
166         if current_name == name ||
167         ( partial_match && current_name.include?( name ) )
168           return true
169         end
170       end
171       false
172     end
173
174     # throws ArgumentError
175     def get_by_name( name, case_sensitive = true, partial_match = false )
176       indices = find_by_name( name, case_sensitive, partial_match )
177       if ( indices.length > 1 )
178         error_msg = "\"" + name + "\" not unique"
179         raise ArgumentError, error_msg
180       elsif ( indices.length < 1 )
181         error_msg = "\"" + name + "\" not found"
182         raise ArgumentError, error_msg
183       end
184       get_sequence( indices[ 0 ] )
185     end
186
187     # throws ArgumentError
188     def get_by_name_start( name, case_sensitive = true )
189       indices = find_by_name_start( name, case_sensitive )
190       if indices.length > 1
191         error_msg = "\"" + name + "\" not unique"
192         raise ArgumentError, error_msg
193       elsif  indices.length < 1
194         error_msg = "\"" + name + "\" not found"
195         raise ArgumentError, error_msg
196       end
197       get_sequence( indices[ 0 ] )
198     end
199
200     def get_sub_alignment( seq_numbers )
201       msa = Msa.new()
202       for i in 0 ... seq_numbers.length()
203         msa.add_sequence( get_sequence( seq_numbers[ i ] ).copy() )
204       end
205       return msa
206     end
207
208     def get_number_of_seqs()
209       @sequences.length
210     end
211
212     def get_length
213       if !is_aligned()
214         error_msg = "attempt to get length of unaligned msa"
215         raise StandardError, error_msg, caller
216       end
217       if get_number_of_seqs() < 1
218         -1
219       else
220         @sequences[ 0 ].get_length()
221       end
222     end
223
224     def to_str
225       to_fasta
226     end
227
228     def to_fasta
229       s = String.new
230       for i in 0...get_number_of_seqs
231         s += @sequences[ i ].to_fasta + Constants::LINE_DELIMITER
232       end
233       s
234     end
235
236     def print_overlap_diagram( min_overlap = 1, io = STDOUT, max_name_length = 10 )
237       if ( !is_aligned() )
238         error_msg = "attempt to get overlap diagram of unaligned msa"
239         raise StandardError, error_msg, caller
240       end
241       for i in 0 ... get_number_of_seqs()
242         io.print( Util.normalize_seq_name( get_sequence( i ).get_name(), max_name_length ) )
243         for j in 0 ... get_number_of_seqs()
244           if i == j
245             io.print( " " )
246           else
247             if overlap?( i, j, min_overlap )
248               io.print( "+" )
249             else
250               io.print( "-" )
251             end
252           end
253         end
254         io.print( Evoruby::Constants::LINE_DELIMITER )
255       end
256     end
257
258     #returns array of Msa with an overlap of min_overlap
259     def split_into_overlapping_msa( min_overlap = 1 )
260       if ( !is_aligned() )
261         error_msg = "attempt to split into overlapping msas of unaligned msa"
262         raise StandardError, error_msg, caller
263       end
264       msas = Array.new()
265       bins = get_overlaps( min_overlap )
266       for i in 0 ... bins.length
267         msas.push( get_sub_alignment( bins[ i ] ) )
268       end
269       msas
270     end
271
272     def overlap?( index_1, index_2, min_overlap = 1 )
273       seq_1 = get_sequence( index_1 )
274       seq_2 = get_sequence( index_2 )
275       overlap_count = 0
276       for i in 0...seq_1.get_length()
277         if !Util.is_aa_gap_character?( seq_1.get_character_code( i ) ) &&
278         !Util.is_aa_gap_character?( seq_2.get_character_code( i ) )
279           overlap_count += 1
280           if overlap_count >= min_overlap
281             return true
282           end
283         end
284       end
285       return false
286     end
287
288     def calculate_overlap( index_1, index_2 )
289       seq_1 = get_sequence( index_1 )
290       seq_2 = get_sequence( index_2 )
291       overlap_count = 0
292       for i in 0...seq_1.get_length
293         if !Util.is_aa_gap_character?( seq_1.get_character_code( i ) ) &&
294         !Util.is_aa_gap_character?( seq_2.get_character_code( i ) )
295           overlap_count += 1
296         end
297       end
298       overlap_count
299     end
300
301     def calculate_identities( index_1, index_2 )
302       seq_1 = get_sequence( index_1 )
303       seq_2 = get_sequence( index_2 )
304       identities_count = 0
305       for i in 0...seq_1.get_length
306         if !Util.is_aa_gap_character?( seq_1.get_character_code( i ) ) &&
307         !Util.is_aa_gap_character?( seq_2.get_character_code( i ) ) &&
308         seq_1.get_character_code( i ) != 63 &&
309         ( seq_1.get_residue( i ).downcase() ==
310         seq_2.get_residue( i ).downcase() )
311           identities_count += 1
312         end
313       end
314       identities_count
315     end
316
317     def remove_gap_only_columns!()
318       remove_columns!( get_gap_only_columns() )
319     end
320
321     def remove_gap_columns!()
322       remove_columns!( get_gap_columns() )
323     end
324
325     # removes columns for which seqs with gap / number of sequences > gap_ratio
326     def remove_gap_columns_w_gap_ratio!( gap_ratio )
327       remove_columns!( get_gap_columns_w_gap_ratio( gap_ratio ) )
328     end
329
330     def remove_sequences_by_gap_ratio!( gap_ratio )
331       if ( !is_aligned() )
332         error_msg = "attempt to remove sequences by gap ratio on unaligned msa"
333         raise StandardError, error_msg, caller
334       end
335       n = get_number_of_seqs
336       removed = Array.new
337       for s in 0 ... n
338         if ( get_sequence( ( n - 1 ) - s  ).get_gap_ratio() > gap_ratio )
339           if ( Evoruby::Constants::VERBOSE )
340             puts( "removed: " + get_sequence( ( n - 1 ) - s  ).get_name )
341           end
342           removed << get_sequence( ( n - 1 ) - s  ).get_name
343           remove_sequence!( ( n - 1 ) - s  )
344         end
345       end
346       removed
347     end
348
349     def remove_redundant_sequences!( consider_taxonomy = false, verbose = false )
350       n = get_number_of_seqs
351       removed = Array.new
352       to_be_removed = Set.new
353       @identical_seqs_detected = Array.new
354       for i in 0 ... ( n - 1 )
355         for j in ( i + 1 ) ... n
356           if !to_be_removed.include?( i ) && !to_be_removed.include?( j )
357             if  !consider_taxonomy ||
358             ( ( get_sequence( i ).get_taxonomy == nil && get_sequence( j ).get_taxonomy == nil ) ||
359             ( get_sequence( i ).get_taxonomy == get_sequence( j ).get_taxonomy ) )
360               if Util.clean_seq_str( get_sequence( i ).get_sequence_as_string ) ==
361               Util.clean_seq_str( get_sequence( j ).get_sequence_as_string )
362                 to_be_removed.add( j )
363
364                 tax_i = ""
365                 tax_j = ""
366                 if get_sequence( i ).get_taxonomy != nil
367                   tax_i = get_sequence( i ).get_taxonomy.get_name
368                 end
369                 if get_sequence( j ).get_taxonomy != nil
370                   tax_j = get_sequence( j ).get_taxonomy.get_name
371                 end
372                 identical_pair = get_sequence( i ).get_name + " [#{tax_i}] == " + get_sequence( j ).get_name + " [#{tax_j}]"
373                 @identical_seqs_detected.push( identical_pair )
374                 if verbose
375                   puts identical_pair
376                 end
377               end
378             end
379           end
380         end
381       end
382
383       to_be_removed_ary = to_be_removed.to_a.sort.reverse
384
385       to_be_removed_ary.each { | index |
386         removed.push( get_sequence( index ).get_name )
387         remove_sequence!( index )
388       }
389       removed
390     end
391
392     def remove_sequences_by_non_gap_length!( min_non_gap_length )
393       n = get_number_of_seqs
394       removed = Array.new
395       for s in 0 ... n
396         x = ( n - 1 ) - s
397         seq = get_sequence( x )
398         if ( ( seq.get_length - seq.get_gap_length ) < min_non_gap_length )
399           if ( Evoruby::Constants::VERBOSE )
400             puts( "removed: " + seq.get_name )
401           end
402           removed << seq.get_name
403           remove_sequence!( x )
404         end
405       end
406       removed
407     end
408
409     def trim!( first, last, name_suffix = nil )
410       cols = Array.new()
411       for i in 0 ... get_length()
412         if ( i < first || i > last )
413           cols.push( i )
414         end
415       end
416       remove_columns!( cols )
417       if name_suffix != nil
418         n = get_number_of_seqs
419         for s in 0 ... n
420           seq = get_sequence( s )
421           seq.set_name( seq.get_name() + name_suffix );
422         end
423       end
424     end
425
426     def extract( first, last )
427       if !is_aligned()
428         error_msg = "attempt to extract from unaligned msa"
429         raise StandardError, error_msg, caller
430       end
431       if first < 0
432         error_msg = "first < 0"
433         raise StandardError, error_msg, caller
434       end
435       if last >= get_length()
436         error_msg = "last > length"
437         raise StandardError, error_msg, caller
438       end
439       if first >= last
440         error_msg = "first >= last"
441         raise StandardError, error_msg, caller
442       end
443       msa = Msa.new()
444       for i in 0 ... get_number_of_seqs
445         msa.add_sequence( get_sequence( i ).get_subsequence( first, last ) )
446       end
447       msa
448     end
449
450     def sliding_extraction( step, size )
451       counter = 0
452       done = false
453       msas = Array.new()
454       while !done
455         first = counter * step
456         last = first + size - 1
457         if last > get_length() - 1
458           last = get_length() - 1
459           done = true
460         end
461         unless first >= last
462           counter +=1
463           res = extract( first, last)
464           res.set_name(first.to_s + "-" + last.to_s)
465           msas << res
466         end
467       end
468       msas
469     end
470
471     def get_gap_only_columns()
472       if ( !is_aligned() )
473         error_msg = "attempt to get gap only columns of unaligned msa"
474         raise StandardError, error_msg, caller
475       end
476       cols = Array.new()
477       for c in 0 ... get_length
478         nogap_char_found = false
479         for s in 0 ... get_number_of_seqs
480           unless Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
481             nogap_char_found = true
482             break
483           end
484         end
485         unless nogap_char_found
486           cols.push( c )
487         end
488       end
489       return cols
490     end
491
492     def calculate_gap_proportion()
493       if ( !is_aligned() )
494         error_msg = "attempt to get gap only columns of unaligned msa"
495         raise StandardError, error_msg, caller
496       end
497       total_sum = 0.0
498       gap_sum = 0.0
499       for c in 0 ... get_length
500         for s in 0 ... get_number_of_seqs
501           total_sum = total_sum + 1
502           if Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
503             gap_sum = gap_sum  + 1
504           end
505         end
506
507       end
508       return gap_sum / total_sum
509     end
510
511     def get_gap_columns()
512       if ( !is_aligned() )
513         error_msg = "attempt to get gap columns of unaligned msa"
514         raise StandardError, error_msg, caller
515       end
516       cols = Array.new()
517       for c in 0 ... get_length
518         gap_char_found = false
519         for s in 0 ... get_number_of_seqs
520           if Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
521             gap_char_found = true
522             break
523           end
524         end
525         if gap_char_found
526           cols.push( c )
527         end
528       end
529       return cols
530     end
531
532     # gap_ratio = seqs with gap / number of sequences
533     # returns column indices for which seqs with gap / number of sequences > gap_ratio
534     def get_gap_columns_w_gap_ratio( gap_ratio )
535       if ( !is_aligned() )
536         error_msg = "attempt to get gap columns with gap_ratio of unaligned msa"
537         raise StandardError, error_msg, caller
538       end
539       if ( gap_ratio < 0 || gap_ratio > 1 )
540         error_msg = "gap ratio must be between 0 and 1 inclusive"
541         raise ArgumentError, error_msg, caller
542       end
543       cols = Array.new()
544       for c in 0 ... get_length
545         gap_chars_found = 0
546         for s in 0 ... get_number_of_seqs
547           if Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
548             gap_chars_found += 1
549           end
550         end
551         if ( ( gap_chars_found.to_f / get_number_of_seqs ) > gap_ratio )
552           cols.push( c )
553         end
554       end
555       return cols
556     end
557
558     # Split an alignment into n alignmnts of equal size, except last one
559     def split( n, verbose = false )
560       if ( n < 2 || n > get_number_of_seqs )
561         error_msg = "attempt to split into less than two or more than the number of sequences"
562         raise StandardError, error_msg, caller
563       end
564       msas = Array.new()
565       r = get_number_of_seqs % n
566       x = get_number_of_seqs / n
567       for i in 0 ... n
568         msa = Msa.new()
569         #s = 0
570         if ( ( r > 0 ) && ( i == ( n - 1 ) ) )
571           y = x + r
572           if ( verbose )
573             puts( i.to_s + ": " + y.to_s )
574           end
575           for j in 0 ... y
576             msa.add_sequence( get_sequence( ( i * x ) + j ) )
577           end
578         else
579           if ( verbose )
580             puts( i.to_s + ": " + x.to_s )
581           end
582           for j in 0 ... x
583             msa.add_sequence( get_sequence( ( i * x ) + j ) )
584           end
585         end
586         msas.push( msa )
587       end
588       msas
589     end
590
591     def split_by_os(verbose = false)
592       msa_hash = Hash.new()
593       for i in 0 ... get_number_of_seqs()
594         seq = get_sequence(i)
595         name = seq.get_name()
596         # >sp|Q1HVE7|AN_EBVA8 Shutoff alkaline exonuclease OS=Epstein-Barr virus (strain AG876) GN=BGLF5 PE=3 SV=1
597         # if name =~ /OS=(.+?)\s+[A-Z]{2}=/
598         if name =~ /Organism:(.+?)(\|Protein|$)/
599           os = $1
600           unless  msa_hash.has_key?(os)
601             msa_hash[os] = Msa.new
602           end
603           msa_hash[os].add_sequence seq
604         else
605           error_msg = "sequence name \"" + name + "\" is not in the expected format for splitting by OS"
606           raise IOError, error_msg, caller
607         end
608       end
609       msa_hash = msa_hash.sort{|a, b|a<=>b}.to_h
610       if verbose
611         c = 0
612         msa_hash.each do |o, msa|
613           c += 1
614           puts c.to_s + ': ' + o
615         end
616       end
617       msa_hash
618     end
619
620     private
621
622     def get_overlaps( min_overlap = 1 )
623       if ( !is_aligned() )
624         error_msg = "attempt to get overlaps of unaligned msa"
625         raise StandardError, error_msg, caller
626       end
627       bins = Array.new()
628       for i in 0 ... get_number_of_seqs()
629         found_bin = false
630         for j in 0 ... bins.length
631           current_bin = bins[ j ]
632           # does seq i overlap with all seqs in current_bin?
633           all_overlap = true
634           for z in 0 ... current_bin.length
635             unless overlap?( i, current_bin[ z ], min_overlap )
636               all_overlap = false
637               break
638             end
639           end
640           if all_overlap
641             current_bin.push( i )
642             found_bin = true
643           end
644         end
645         unless found_bin
646           new_bin = Array.new()
647           new_bin.push( i )
648           bins.push( new_bin )
649         end
650       end
651       return bins
652     end
653
654     def remove_columns!( cols )
655       if ( !is_aligned() )
656         error_msg = "attempt to remove columns of unaligned msa"
657         raise StandardError, error_msg, caller
658       end
659       cols.reverse!()
660       for c in 0 ... cols.length()
661         col = cols[ c ]
662         for s in 0 ... get_number_of_seqs()
663           get_sequence( s ).delete_residue!( col )
664         end
665       end
666       return self
667     end
668
669   end # class Msa
670
671 end # module Evoruby
672