]> git.donarmstrong.com Git - biopieces.git/blob - code_ruby/lib/maasha/seq.rb
making changes to accomodate Illumina18 qual scores
[biopieces.git] / code_ruby / lib / maasha / seq.rb
1 # Copyright (C) 2007-2012 Martin A. Hansen.
2
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
7
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software
15 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16
17 # http://www.gnu.org/copyleft/gpl.html
18
19 # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>><<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
20
21 # This software is part of the Biopieces framework (www.biopieces.org).
22
23 # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>><<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
24
25 require 'maasha/patternmatcher'
26 require 'maasha/bits'
27 require 'maasha/backtrack'
28 require 'maasha/digest'
29 require 'narray'
30 #require 'maasha/patscan'
31
32 # Residue alphabets
33 DNA     = %w[a t c g]
34 RNA     = %w[a u c g]
35 PROTEIN = %w[f l s y c w p h q r i m t n k v a d e g]
36 INDELS  = %w[. - _ ~]
37
38 # Quality scores bases
39 SCORE_BASE = 64
40 SCORE_MIN  = 0
41 SCORE_MAX  = 40
42
43 # Error class for all exceptions to do with Seq.
44 class SeqError < StandardError; end
45
46 class Seq
47   #include Patscan
48   include PatternMatcher
49   include BackTrack
50   include Digest
51
52   attr_accessor :seq_name, :seq, :type, :qual
53
54   # Class method to instantiate a new Sequence object given
55   # a Biopiece record.
56   def self.new_bp(record)
57     seq_name = record[:SEQ_NAME]
58     seq      = record[:SEQ]
59     type     = record[:SEQ_TYPE]
60     qual     = record[:SCORES]
61
62     self.new(seq_name, seq, type, qual)
63   end
64
65   # Class method that generates all possible oligos of a specifed length and type.
66   def self.generate_oligos(length, type)
67     raise SeqError, "Cannot generate negative oligo length: #{length}" if length <= 0
68
69     case type.downcase
70     when /dna/     then alph = DNA
71     when /rna/     then alph = RNA
72     when /protein/ then alph = PROTEIN
73     else
74       raise SeqError, "Unknown sequence type: #{type}"
75     end
76
77     oligos = [""]
78
79     (1 .. length).each do
80       list = []
81
82       oligos.each do |oligo|
83         alph.each do |char|
84           list << oligo + char
85         end
86       end
87
88       oligos = list
89     end
90
91     oligos
92   end
93
94   # Initialize a sequence object with the following arguments:
95   # - seq_name: Name of the sequence.
96   # - seq: The sequence.
97   # - type: The sequence type - DNA, RNA, or protein
98   # - qual: An Illumina type quality scores string.
99   def initialize(seq_name = nil, seq = nil, type = nil, qual = nil)
100     @seq_name = seq_name
101     @seq      = seq
102     @type     = type
103     @qual     = qual
104   end
105
106   # Method that guesses and returns the sequence type
107   # by inspecting the first 100 residues.
108   def type_guess
109     raise SeqError, "Guess failed: sequence is nil" if self.seq.nil?
110
111     case self.seq[0 ... 100].downcase
112     when /[flpqie]/ then return "protein"
113     when /[u]/      then return "rna"
114     else                 return "dna"
115     end
116   end
117
118   # Method that guesses and sets the sequence type
119   # by inspecting the first 100 residues.
120   def type_guess!
121     self.type = self.type_guess
122   end
123
124   # Returns the length of a sequence.
125   def length
126     self.seq.nil? ? 0 : self.seq.length
127   end
128
129   alias :len :length
130
131   # Return the number indels in a sequence.
132   def indels
133     regex = Regexp.new(/[#{Regexp.escape(INDELS.join(""))}]/)
134     self.seq.scan(regex).size
135   end
136
137   # Method to remove indels from seq and qual if qual.
138   def indels_remove
139     if self.qual.nil?
140       self.seq.delete!(Regexp.escape(INDELS.join('')))
141     else
142       na_seq  = NArray.to_na(self.seq, "byte")
143       na_qual = NArray.to_na(self.qual, "byte")
144       mask    = NArray.byte(self.length)
145
146       INDELS.each do |c|
147         mask += na_seq.eq(c.ord)
148       end
149
150       mask = mask.eq(0)
151
152       self.seq  = na_seq[mask].to_s
153       self.qual = na_qual[mask].to_s
154     end
155
156     self
157   end
158
159   # Method that returns true is a given sequence type is DNA.
160   def is_dna?
161     self.type == 'dna'
162   end
163
164   # Method that returns true is a given sequence type is RNA.
165   def is_rna?
166     self.type == 'rna'
167   end
168
169   # Method that returns true is a given sequence type is protein.
170   def is_protein?
171     self.type == 'protein'
172   end
173
174   # Method to transcribe DNA to RNA.
175   def to_rna
176     raise SeqError, "Cannot transcribe 0 length sequence" if self.length == 0
177     raise SeqError, "Cannot transcribe sequence type: #{self.type}" unless self.is_dna?
178     self.type = 'rna'
179     self.seq.tr!('Tt','Uu')
180   end
181
182   # Method to reverse-transcribe RNA to DNA.
183   def to_dna
184     raise SeqError, "Cannot reverse-transcribe 0 length sequence" if self.length == 0
185     raise SeqError, "Cannot reverse-transcribe sequence type: #{self.type}" unless self.is_rna?
186
187     self.type = 'dna'
188     self.seq.tr!('Uu','Tt')
189   end
190
191   # Method that given a Seq entry returns a Biopieces record (a hash).
192   def to_bp
193     raise SeqError, "Missing seq_name" if self.seq_name.nil?
194     raise SeqError, "Missing seq"      if self.seq.nil?
195
196     record             = {}
197     record[:SEQ_NAME] = self.seq_name
198     record[:SEQ]      = self.seq
199     record[:SEQ_LEN]  = self.length
200     record[:SCORES]   = self.qual if self.qual
201     record
202   end
203
204   # Method that given a Seq entry returns a FASTA entry (a string).
205   def to_fasta(wrap = nil)
206     raise SeqError, "Missing seq_name" if self.seq_name.nil? or self.seq_name == ''
207     raise SeqError, "Missing seq"      if self.seq.nil?      or self.seq.empty?
208
209     seq_name = self.seq_name.to_s
210     seq      = self.seq.to_s
211
212     unless wrap.nil?
213       seq.gsub!(/(.{#{wrap}})/) do |match|
214         match << $/
215       end
216
217       seq.chomp!
218     end
219
220     ">" + seq_name + $/ + seq + $/
221   end
222
223   # Method that given a Seq entry returns a FASTQ entry (a string).
224   def to_fastq
225     raise SeqError, "Missing seq_name" if self.seq_name.nil?
226     raise SeqError, "Missing seq"      if self.seq.nil?
227     raise SeqError, "Missing qual"     if self.qual.nil?
228
229     seq_name = self.seq_name.to_s
230     seq      = self.seq.to_s
231     qual     = self.qual.to_s
232
233     "@" + seq_name + $/ + seq + $/ + "+" + $/ + qual + $/
234   end
235
236   # Method that generates a unique key for a
237   # DNA sequence and return this key as a Fixnum.
238   def to_key
239     key = 0
240     
241     self.seq.upcase.each_char do |char|
242       key <<= 2
243       
244       case char
245       when 'A' then key |= 0
246       when 'C' then key |= 1
247       when 'G' then key |= 2
248       when 'T' then key |= 3
249       else raise SeqError, "Bad residue: #{char}"
250       end
251     end
252     
253     key
254   end
255
256   # Method to reverse complement sequence.
257   def reverse_complement
258     self.reverse
259     self.complement
260     self
261   end
262
263   alias :revcomp :reverse_complement
264
265   # Method to reverse the sequence.
266   def reverse
267     self.seq.reverse!
268     self.qual.reverse! if self.qual
269     self
270   end
271
272   # Method that complements sequence including ambiguity codes.
273   def complement
274     raise SeqError, "Cannot complement 0 length sequence" if self.length == 0
275
276     if self.is_dna?
277       self.seq.tr!('AGCUTRYWSMKHDVBNagcutrywsmkhdvbn', 'TCGAAYRWSKMDHBVNtcgaayrwskmdhbvn')
278     elsif self.is_rna?
279       self.seq.tr!('AGCUTRYWSMKHDVBNagcutrywsmkhdvbn', 'UCGAAYRWSKMDHBVNucgaayrwskmdhbvn')
280     else
281       raise SeqError, "Cannot complement sequence type: #{self.type}"
282     end
283   end
284
285   # Method to determine the Hamming Distance between
286   # two Sequence objects (case insensitive).
287   def hamming_distance(seq)
288     self.seq.upcase.hamming_distance(seq.seq.upcase)
289   end
290
291   # Method that generates a random sequence of a given length and type.
292   def generate(length, type)
293     raise SeqError, "Cannot generate sequence length < 1: #{length}" if length <= 0
294
295     case type.downcase
296     when "dna"
297       alph = DNA
298     when "rna"
299       alph = RNA
300     when "protein"
301       alph = PROTEIN
302     else
303       raise SeqError, "Unknown sequence type: #{type}"
304     end
305
306     seq_new   = Array.new(length) { alph[rand(alph.size)] }.join("")
307     self.seq  = seq_new
308     self.type = type.downcase
309     seq_new
310   end
311
312   # Method to shuffle a sequence readomly inline.
313   def shuffle!
314     self.seq = self.seq.split('').shuffle!.join
315     self
316   end
317
318   # Method that returns a subsequence of from a given start position
319   # and of a given length.
320   def subseq(start, length = self.length - start)
321     raise SeqError, "subsequence start: #{start} < 0"                                                if start  < 0
322     raise SeqError, "subsequence length: #{length} < 1"                                              if length <= 0
323     raise SeqError, "subsequence start + length > Seq.length: #{start} + #{length} > #{self.length}" if start + length > self.length
324
325     stop = start + length - 1
326
327     seq  = self.seq[start .. stop]
328     qual = self.qual[start .. stop] unless self.qual.nil?
329
330     Seq.new(self.seq_name, seq, self.type, qual)
331   end
332
333   # Method that replaces a sequence with a subsequence from a given start position
334   # and of a given length.
335   def subseq!(start, length = self.length - start)
336     raise SeqError, "subsequence start: #{start} < 0"                                                if start  < 0
337     raise SeqError, "subsequence length: #{length} < 1"                                              if length <= 0
338     raise SeqError, "subsequence start + length > Seq.length: #{start} + #{length} > #{self.length}" if start + length > self.length
339
340     stop = start + length - 1
341
342     self.seq  = self.seq[start .. stop]
343     self.qual = self.qual[start .. stop] unless self.qual.nil?
344   end
345
346   # Method that returns a subsequence of a given length
347   # beginning at a random position.
348   def subseq_rand(length)
349     if self.length - length + 1 == 0
350       start = 0
351     else
352       start = rand(self.length - length + 1)
353     end
354
355     self.subseq(start, length)
356   end
357
358   def quality_trim_right(min)
359     raise SeqError, "no sequence"      if self.seq.nil?
360     raise SeqError, "no quality score" if self.qual.nil?
361     raise SeqError, "minimum value: #{min} out of range #{SCORE_MIN} .. #{SCORE_MAX}" unless (SCORE_MIN .. SCORE_MAX).include? min
362
363     regex_right = Regexp.new("[#{(SCORE_BASE).chr}-#{(SCORE_BASE + min).chr}]+$")
364
365     self.qual.match(regex_right) do |m|
366       self.subseq!(0, $`.length) if $`.length > 0
367     end
368
369     self
370   end
371
372   def quality_trim_left(min)
373     raise SeqError, "no sequence"      if self.seq.nil?
374     raise SeqError, "no quality score" if self.qual.nil?
375     raise SeqError, "minimum value: #{min} out of range #{SCORE_MIN} .. #{SCORE_MAX}" unless (SCORE_MIN .. SCORE_MAX).include? min
376
377     regex_left  = Regexp.new("^[#{(SCORE_BASE).chr}-#{(SCORE_BASE + min).chr}]+")
378
379     self.qual.match(regex_left) do |m|
380       self.subseq!(m.to_s.length, self.length - m.to_s.length) if self.length - m.to_s.length > 0
381     end
382
383     self
384   end
385
386   def quality_trim(min)
387     self.quality_trim_right(min)
388     self.quality_trim_left(min)
389     self
390   end
391
392   # Method that returns the residue compositions of a sequence in
393   # a hash where the key is the residue and the value is the residue
394   # count.
395   def composition
396     comp = Hash.new(0);
397
398     self.seq.upcase.each_char do |char|
399       comp[char] += 1
400     end
401
402     comp
403   end
404
405   # Method that returns the length of the longest homopolymeric stretch
406   # found in a sequence.
407   def homopol_max(min = 1)
408     return 0 if self.seq.nil? or self.seq.empty?
409
410     found = false
411
412     self.seq.upcase.scan(/A{#{min},}|T{#{min},}|G{#{min},}|C{#{min},}|N{#{min},}/) do |match|
413       found = true
414       min   = match.size > min ? match.size : min
415     end
416
417     return 0 unless found
418  
419     min
420   end
421
422   # Method that returns the percentage of hard masked residues
423   # or N's in a sequence.
424   def hard_mask
425     ((self.seq.upcase.scan("N").size.to_f / (self.len - self.indels).to_f) * 100).round(2)
426   end
427
428   # Method that returns the percentage of soft masked residues
429   # or lower cased residues in a sequence.
430   def soft_mask
431     ((self.seq.scan(/[a-z]/).size.to_f / (self.len - self.indels).to_f) * 100).round(2)
432   end
433
434   # Hard masks sequence residues where the corresponding quality score
435   # is below a given cutoff.
436   def mask_seq_hard_old(cutoff)
437     seq    = self.seq.upcase
438     scores = self.qual
439     i      = 0
440
441     scores.each_char do |score|
442       seq[i] = 'N' if score.ord - SCORE_BASE < cutoff
443       i += 1 
444     end
445
446     self.seq = seq
447   end
448
449   # Hard masks sequence residues where the corresponding quality score
450   # is below a given cutoff.
451   def mask_seq_hard!(cutoff)
452     raise SeqError, "seq is nil"  if self.seq.nil?
453     raise SeqError, "qual is nil" if self.qual.nil?
454     raise SeqError, "cufoff value: #{cutoff} out of range #{SCORE_MIN} .. #{SCORE_MAX}" unless (SCORE_MIN .. SCORE_MAX).include? cutoff
455
456     na_seq  = NArray.to_na(self.seq, "byte")
457     na_qual = NArray.to_na(self.qual, "byte")
458     mask    = (na_qual - SCORE_BASE) < cutoff
459     mask   *= na_seq.ne("-".ord)
460
461     na_seq[mask] = 'N'.ord
462
463     self.seq = na_seq.to_s
464
465     self
466   end
467
468   # Soft masks sequence residues where the corresponding quality score
469   # is below a given cutoff.
470   def mask_seq_soft!(cutoff)
471     raise SeqError, "seq is nil"  if self.seq.nil?
472     raise SeqError, "qual is nil" if self.qual.nil?
473     raise SeqError, "cufoff value: #{cutoff} out of range #{SCORE_MIN} .. #{SCORE_MAX}" unless (SCORE_MIN .. SCORE_MAX).include? cutoff
474
475     na_seq  = NArray.to_na(self.seq, "byte")
476     na_qual = NArray.to_na(self.qual, "byte")
477     mask    = (na_qual - SCORE_BASE) < cutoff
478     mask   *= na_seq.ne("-".ord)
479
480     na_seq[mask] ^= ' '.ord
481
482     self.seq = na_seq.to_s
483
484     self
485   end
486
487   # Method to convert quality scores inbetween formats.
488   # Sanger     base 33, range  0-40 
489   # Solexa     base 64, range -5-40 
490   # Illumina13 base 64, range  0-40 
491   # Illumina15 base 64, range  3-40 
492   # Illumina18 base 33, range  0-41 
493   def convert_scores!(from, to)
494     unless from == to
495       na_qual = NArray.to_na(self.qual, "byte")
496
497       case from.downcase
498       when "sanger"     then na_qual -= 33
499       when "solexa"     then na_qual -= 64
500       when "illumina13" then na_qual -= 64
501       when "illumina15" then na_qual -= 64
502       when "illumina18" then na_qual -= 33
503       else raise SeqError, "unknown quality score encoding: #{from}"
504       end
505
506       case to.downcase
507       when "sanger"     then na_qual += 33
508       when "solexa"     then na_qual += 64
509       when "illumina13" then na_qual += 64
510       when "illumina15" then na_qual += 64
511       when "illumina18" then na_qual += 33
512       else raise SeqError, "unknown quality score encoding: #{from}"
513       end
514
515       self.qual = na_qual.to_s
516     end
517
518     self
519   end
520 end
521
522 __END__