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, suffix )
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         subseq = get_sequence( i ).get_subsequence( first, last )
446         if suffix != nil
447           msa.add( subseq.get_name + suffix, subseq.get_sequence_as_string )
448         else
449           msa.add( subseq.get_name, subseq.get_sequence_as_string )
450         end
451       end
452
453       msa
454     end
455
456     def sliding_extraction( step, size, suffix = nil )
457       counter = 0
458       done = false
459       msas = Array.new()
460       while !done
461         first = counter * step
462         last = first + size - 1
463         if last > get_length() - 1
464           last = get_length() - 1
465           done = true
466         end
467         unless first >= last
468           counter +=1
469           res = extract(first, last, suffix)
470           res.set_name(first.to_s + "-" + last.to_s)
471           msas << res
472         end
473       end
474       msas
475     end
476
477     def get_gap_only_columns()
478       if ( !is_aligned() )
479         error_msg = "attempt to get gap only columns of unaligned msa"
480         raise StandardError, error_msg, caller
481       end
482       cols = Array.new()
483       for c in 0 ... get_length
484         nogap_char_found = false
485         for s in 0 ... get_number_of_seqs
486           unless Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
487             nogap_char_found = true
488             break
489           end
490         end
491         unless nogap_char_found
492           cols.push( c )
493         end
494       end
495       return cols
496     end
497
498     def calculate_gap_proportion()
499       if ( !is_aligned() )
500         error_msg = "attempt to get gap only columns of unaligned msa"
501         raise StandardError, error_msg, caller
502       end
503       total_sum = 0.0
504       gap_sum = 0.0
505       for c in 0 ... get_length
506         for s in 0 ... get_number_of_seqs
507           total_sum = total_sum + 1
508           if Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
509             gap_sum = gap_sum  + 1
510           end
511         end
512
513       end
514       return gap_sum / total_sum
515     end
516
517     def get_gap_columns()
518       if ( !is_aligned() )
519         error_msg = "attempt to get gap columns of unaligned msa"
520         raise StandardError, error_msg, caller
521       end
522       cols = Array.new()
523       for c in 0 ... get_length
524         gap_char_found = false
525         for s in 0 ... get_number_of_seqs
526           if Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
527             gap_char_found = true
528             break
529           end
530         end
531         if gap_char_found
532           cols.push( c )
533         end
534       end
535       return cols
536     end
537
538     # gap_ratio = seqs with gap / number of sequences
539     # returns column indices for which seqs with gap / number of sequences > gap_ratio
540     def get_gap_columns_w_gap_ratio( gap_ratio )
541       if ( !is_aligned() )
542         error_msg = "attempt to get gap columns with gap_ratio of unaligned msa"
543         raise StandardError, error_msg, caller
544       end
545       if ( gap_ratio < 0 || gap_ratio > 1 )
546         error_msg = "gap ratio must be between 0 and 1 inclusive"
547         raise ArgumentError, error_msg, caller
548       end
549       cols = Array.new()
550       for c in 0 ... get_length
551         gap_chars_found = 0
552         for s in 0 ... get_number_of_seqs
553           if Util.is_aa_gap_character?( get_sequence( s ).get_character_code( c ) )
554             gap_chars_found += 1
555           end
556         end
557         if ( ( gap_chars_found.to_f / get_number_of_seqs ) > gap_ratio )
558           cols.push( c )
559         end
560       end
561       return cols
562     end
563
564     # Split an alignment into n alignmnts of equal size, except last one
565     def split( n, verbose = false )
566       if ( n < 2 || n > get_number_of_seqs )
567         error_msg = "attempt to split into less than two or more than the number of sequences"
568         raise StandardError, error_msg, caller
569       end
570       msas = Array.new()
571       r = get_number_of_seqs % n
572       x = get_number_of_seqs / n
573       for i in 0 ... n
574         msa = Msa.new()
575         #s = 0
576         if ( ( r > 0 ) && ( i == ( n - 1 ) ) )
577           y = x + r
578           if ( verbose )
579             puts( i.to_s + ": " + y.to_s )
580           end
581           for j in 0 ... y
582             msa.add_sequence( get_sequence( ( i * x ) + j ) )
583           end
584         else
585           if ( verbose )
586             puts( i.to_s + ": " + x.to_s )
587           end
588           for j in 0 ... x
589             msa.add_sequence( get_sequence( ( i * x ) + j ) )
590           end
591         end
592         msas.push( msa )
593       end
594       msas
595     end
596
597     def split_by_os(verbose = false)
598       msa_hash = Hash.new()
599       for i in 0 ... get_number_of_seqs()
600         seq = get_sequence(i)
601         name = seq.get_name()
602         # >sp|Q1HVE7|AN_EBVA8 Shutoff alkaline exonuclease OS=Epstein-Barr virus (strain AG876) GN=BGLF5 PE=3 SV=1
603         # if name =~ /OS=(.+?)\s+[A-Z]{2}=/
604         if name =~ /Organism:(.+?)(\|Protein|$)/
605           os = $1
606           unless  msa_hash.has_key?(os)
607             msa_hash[os] = Msa.new
608           end
609           msa_hash[os].add_sequence seq
610         else
611           error_msg = "sequence name \"" + name + "\" is not in the expected format for splitting by OS"
612           raise IOError, error_msg, caller
613         end
614       end
615       msa_hash = msa_hash.sort{|a, b|a<=>b}.to_h
616       if verbose
617         c = 0
618         msa_hash.each do |o, msa|
619           c += 1
620           puts c.to_s + ': ' + o
621         end
622       end
623       msa_hash
624     end
625
626     private
627
628     def get_overlaps( min_overlap = 1 )
629       if ( !is_aligned() )
630         error_msg = "attempt to get overlaps of unaligned msa"
631         raise StandardError, error_msg, caller
632       end
633       bins = Array.new()
634       for i in 0 ... get_number_of_seqs()
635         found_bin = false
636         for j in 0 ... bins.length
637           current_bin = bins[ j ]
638           # does seq i overlap with all seqs in current_bin?
639           all_overlap = true
640           for z in 0 ... current_bin.length
641             unless overlap?( i, current_bin[ z ], min_overlap )
642               all_overlap = false
643               break
644             end
645           end
646           if all_overlap
647             current_bin.push( i )
648             found_bin = true
649           end
650         end
651         unless found_bin
652           new_bin = Array.new()
653           new_bin.push( i )
654           bins.push( new_bin )
655         end
656       end
657       return bins
658     end
659
660     def remove_columns!( cols )
661       if ( !is_aligned() )
662         error_msg = "attempt to remove columns of unaligned msa"
663         raise StandardError, error_msg, caller
664       end
665       cols.reverse!()
666       for c in 0 ... cols.length()
667         col = cols[ c ]
668         for s in 0 ... get_number_of_seqs()
669           get_sequence( s ).delete_residue!( col )
670         end
671       end
672       return self
673     end
674
675   end # class Msa
676
677 end # module Evoruby
678