///|
/// Quality and composition findings for a sequence supplied by a caller.
pub struct SequenceQualityReport {
sequence_length : Int
canonical_bases : Int
ambiguous_bases : Int
invalid_bases : Int
gc_percent : Double
longest_run : Int
run_count : Int
has_internal_gap : Bool
valid : Bool
} derive(Debug, Eq)
///|
/// A normalized interval used by trimming and masking workflows.
pub struct SequenceInterval {
start : Int
end : Int
sequence : String
} derive(Debug, Eq)
///|
/// Analyze canonical, ambiguous, gap, and invalid symbols without silently
/// discarding input. Coordinates in all reports are one-based and inclusive.
pub fn sequence_quality_report(sequence : String) -> SequenceQualityReport {
let upper = sequence.to_upper()
let mut canonical = 0
let mut ambiguous = 0
let mut invalid = 0
let mut gc = 0
let mut longest = 0
let mut current = 0
let mut previous : Char? = None
let mut runs = 0
let mut internal_gap = false
for index in 0.. {
canonical += 1
if ch == 'C' || ch == 'G' {
gc += 1
}
}
'R' | 'Y' | 'S' | 'W' | 'K' | 'M' | 'B' | 'D' | 'H' | 'V' | 'N' =>
ambiguous += 1
'-' | '.' =>
if index > 0 && index + 1 < upper.length() {
internal_gap = true
}
_ => invalid += 1
}
match previous {
Some(old) if old == ch => current += 1
_ => {
current = 1
runs += 1
}
}
previous = Some(ch)
if current > longest {
longest = current
}
}
let length = upper.length()
let gc_percent = if canonical == 0 {
0.0
} else {
gc.to_double() * 100.0 / canonical.to_double()
}
{
sequence_length: length,
canonical_bases: canonical,
ambiguous_bases: ambiguous,
invalid_bases: invalid,
gc_percent,
longest_run: longest,
run_count: runs,
has_internal_gap: internal_gap,
valid: invalid == 0 && !internal_gap,
}
}
///|
/// Return positions of symbols that are not valid IUPAC nucleotides.
pub fn invalid_base_positions(sequence : String) -> Array[Int] {
let positions : Array[Int] = []
for index in 0.. Array[Int] {
let positions : Array[Int] = []
for index in 0.. ()
_ if is_iupac_base(ch) => positions.push(index + 1)
_ => ()
}
}
positions
}
///|
/// Find every homopolymer run of at least `minimum_length`.
pub fn homopolymer_runs(
sequence : String,
minimum_length : Int,
) -> Array[SequenceRun] {
let result : Array[SequenceRun] = []
let upper = sequence.to_upper()
if upper.length() == 0 || minimum_length <= 0 {
return result
}
let mut start = 0
let mut index = 1
while index <= upper.length() {
let different = if index == upper.length() {
true
} else {
upper.get_char(index).unwrap() != upper.get_char(start).unwrap()
}
if different {
let length = index - start
if length >= minimum_length {
result.push({
base: upper.get_char(start).unwrap(),
start: start + 1,
end: index,
length,
})
}
start = index
}
index += 1
}
result
}
///|
/// Produce non-overlapping windows containing GC skew `(G-C)/(G+C)`.
pub fn gc_skew_windows(
sequence : String,
window_size : Int,
step : Int,
) -> Array[(Int, Int, Double)] {
let result : Array[(Int, Int, Double)] = []
if window_size <= 0 || step <= 0 || window_size > sequence.length() {
return result
}
let upper = sequence.to_upper()
let mut start = 0
while start + window_size <= upper.length() {
let end = start + window_size
let mut g = 0
let mut c = 0
for index in start.. Array[SequenceInterval] {
let result : Array[SequenceInterval] = []
let upper = sequence.to_upper()
let mut start = -1
for index in 0..<=upper.length() {
let gap = if index == upper.length() {
true
} else {
let ch = upper.get_char(index).unwrap()
ch == '-' || ch == '.'
}
if !gap && start < 0 {
start = index
} else if gap && start >= 0 {
result.push({
start: start + 1,
end: index,
sequence: upper.unsafe_substring(start~, end=index),
})
start = -1
}
}
result
}
///|
/// Mask ambiguity symbols to `N` while retaining gaps and their coordinates.
pub fn mask_ambiguities(sequence : String) -> String {
let out = StringBuilder()
for ch in sequence.to_upper() {
match ch {
'A' | 'C' | 'G' | 'T' | 'U' | '-' | '.' => out.write_char(ch)
_ if is_iupac_base(ch) => out.write_char('N')
_ => out.write_char('N')
}
}
out.to_string()
}
///|
/// Validate that a sequence has no invalid symbols and no internal gaps.
pub fn sequence_is_clean(sequence : String) -> Bool {
sequence_quality_report(sequence).valid
}
///|
/// Render a compact machine-readable quality report.
pub fn SequenceQualityReport::to_json_string(
self : SequenceQualityReport,
) -> String {
"{\"sequence_length\":" +
self.sequence_length.to_string() +
",\"canonical_bases\":" +
self.canonical_bases.to_string() +
",\"ambiguous_bases\":" +
self.ambiguous_bases.to_string() +
",\"invalid_bases\":" +
self.invalid_bases.to_string() +
",\"gc_percent\":" +
self.gc_percent.to_string() +
",\"longest_run\":" +
self.longest_run.to_string() +
",\"run_count\":" +
self.run_count.to_string() +
",\"has_internal_gap\":" +
self.has_internal_gap.to_string() +
",\"valid\":" +
self.valid.to_string() +
"}"
}
///|
pub fn SequenceRun::to_text(self : SequenceRun) -> String {
self.base.to_string() +
" " +
self.start.to_string() +
".." +
self.end.to_string() +
" (" +
self.length.to_string() +
")"
}