///|
/// A FASTA sequence with its identifier and optional description.
pub struct FastaRecord {
id : String
description : String
sequence : String
} derive(Debug, Eq)
///|
/// A sequence window and coordinates in the original sequence.
pub struct SequenceWindow {
start : Int
end : Int
sequence : String
gc_percent : Double
} derive(Debug, Eq)
///|
/// One k-mer and its number of occurrences.
pub struct KmerCount {
kmer : String
count : Int
} derive(Debug, Eq)
///|
/// Translation details useful for validation reports.
pub struct TranslationReport {
protein : String
codons : Int
stops : Int
ambiguous : Int
trailing_bases : Int
} derive(Debug, Eq)
///|
/// Composition and diversity statistics for a nucleotide sequence.
pub struct CompositionReport {
length : Int
a : Int
c : Int
g : Int
t : Int
ambiguous : Int
gc_percent : Double
entropy : Double
} derive(Debug, Eq)
///|
/// Find every overlapping occurrence of a motif, using zero-based positions.
pub fn find_motif_positions(sequence : String, motif : String) -> Array[Int] {
let seq = sequence.to_upper()
let needle = motif.to_upper()
let hits : Array[Int] = []
if needle == "" || needle.length() > seq.length() {
return hits
}
for i in 0..<=(seq.length() - needle.length()) {
if seq.unsafe_substring(start=i, end=i + needle.length()) == needle {
hits.push(i)
}
}
hits
}
///|
/// Return overlapping windows. Invalid window sizes produce an empty array.
pub fn sequence_windows(
sequence : String,
window_size : Int,
step : Int,
) -> Array[SequenceWindow] {
let windows : Array[SequenceWindow] = []
if window_size <= 0 || step <= 0 || window_size > sequence.length() {
return windows
}
let mut start = 0
while start + window_size <= sequence.length() {
let end = start + window_size
let piece = sequence.unsafe_substring(start~, end~).to_upper()
let stats = composition_report(piece)
windows.push({
start: start + 1,
end,
sequence: piece,
gc_percent: stats.gc_percent,
})
start += step
}
windows
}
///|
/// Count k-mers in first-seen order, making reports stable across targets.
pub fn kmer_profile(sequence : String, k : Int) -> Array[KmerCount] {
let result : Array[KmerCount] = []
let upper = sequence.to_upper()
if k <= 0 || k > upper.length() {
return result
}
for i in 0..<=(upper.length() - k) {
let kmer = upper.unsafe_substring(start=i, end=i + k)
match result.search_by(fn(item) { item.kmer == kmer }) {
Some(index) => {
let item = result[index]
result[index] = { kmer: item.kmer, count: item.count + 1 }
}
None => result.push({ kmer, count: 1 })
}
}
result
}
///|
/// Whether a character is one of the 15 standard IUPAC nucleotide symbols.
pub fn is_iupac_base(ch : Char) -> Bool {
match ch {
'A'
| 'C'
| 'G'
| 'T'
| 'U'
| 'R'
| 'Y'
| 'S'
| 'W'
| 'K'
| 'M'
| 'B'
| 'D'
| 'H'
| 'V'
| 'N' => true
'a'
| 'c'
| 'g'
| 't'
| 'u'
| 'r'
| 'y'
| 's'
| 'w'
| 'k'
| 'm'
| 'b'
| 'd'
| 'h'
| 'v'
| 'n' => true
_ => false
}
}
///|
/// Expand an IUPAC code to sorted canonical bases.
pub fn iupac_expansion(ch : Char) -> Array[Char] {
match ch.to_string().to_upper() {
"A" => ['A']
"C" => ['C']
"G" => ['G']
"T" | "U" => ['T']
"R" => ['A', 'G']
"Y" => ['C', 'T']
"S" => ['G', 'C']
"W" => ['A', 'T']
"K" => ['G', 'T']
"M" => ['A', 'C']
"B" => ['C', 'G', 'T']
"D" => ['A', 'G', 'T']
"H" => ['A', 'C', 'T']
"V" => ['A', 'C', 'G']
"N" => ['A', 'C', 'G', 'T']
_ => []
}
}
///|
/// Parse FASTA records while preserving the first-line description.
pub fn parse_fasta(text : String) -> Array[FastaRecord] raise GenBankError {
let records : Array[FastaRecord] = []
let mut current_id = ""
let mut current_description = ""
let sequence = StringBuilder()
for line in text.split("\n") {
let trimmed = line.trim().to_owned()
if trimmed == "" {
continue
}
if trimmed.has_prefix(">") {
if current_id != "" {
records.push({
id: current_id,
description: current_description,
sequence: sequence.to_string().to_upper(),
})
sequence.reset()
}
let header = trimmed.unsafe_substring(start=1, end=trimmed.length())
let words = header.split(" ").map(fn(item) { item.to_owned() }).to_array()
current_id = if words.length() == 0 { "" } else { words[0] }
current_description = if words.length() <= 1 {
""
} else {
words.view(start=1).join(" ")
}
} else {
if current_id == "" {
raise GenBankError::MissingSection("FASTA header")
}
for ch in trimmed {
if is_iupac_base(ch) {
sequence.write_char(ch)
}
}
}
}
if current_id != "" {
records.push({
id: current_id,
description: current_description,
sequence: sequence.to_string().to_upper(),
})
}
records
}
///|
/// Translate a nucleotide sequence and retain quality diagnostics.
pub fn translate_dna_report(seq : String) -> TranslationReport {
let upper = seq.to_upper().replace_all(old="U", new="T")
let codon_count = upper.length() / 3
let amino : Array[String] = []
let mut stops = 0
let mut ambiguous = 0
for i in 0.. CompositionReport {
let mut a = 0
let mut c = 0
let mut g = 0
let mut t = 0
let mut ambiguous = 0
for ch in sequence.to_upper() {
match ch {
'A' => a += 1
'C' => c += 1
'G' => g += 1
'T' | 'U' => t += 1
_ => ambiguous += 1
}
}
let length = a + c + g + t + ambiguous
let gc_percent = if length == 0 {
0.0
} else {
(g + c).to_double() * 100.0 / length.to_double()
}
let entropy = entropy_from_counts([a, c, g, t], length)
{ length, a, c, g, t, ambiguous, gc_percent, entropy }
}
///|
fn entropy_from_counts(counts : Array[Int], total : Int) -> Double {
if total == 0 {
return 0.0
}
let mut value = 0.0
for count in counts {
if count > 0 {
let p = count.to_double() / total.to_double()
value -= p * log2(p)
}
}
value
}
///|
fn log2(value : Double) -> Double {
// A compact approximation is sufficient for a deterministic diversity
// report and keeps the package independent from platform-specific FFI.
let mut x = value
let mut exponent = 0
while x < 1.0 {
x *= 2.0
exponent -= 1
}
while x >= 2.0 {
x /= 2.0
exponent += 1
}
let y = (x - 1.0) / (x + 1.0)
let y2 = y * y
let mut term = y
let mut sum = 0.0
for n in 0..<10 {
sum += term / (2.0 * n.to_double() + 1.0)
term *= y2
}
2.0 * sum / 0.6931471805599453 + exponent.to_double()
}
///|
pub fn FastaRecord::to_fasta(self : FastaRecord, line_width : Int) -> String {
let width = if line_width <= 0 { 60 } else { line_width }
let header = if self.description == "" {
">" + self.id
} else {
">" + self.id + " " + self.description
}
let out = StringBuilder()
out.write_string(header)
out.write_char('\n')
let mut start = 0
while start < self.sequence.length() {
let end = Int::min(start + width, self.sequence.length())
out.write_string(self.sequence.unsafe_substring(start~, end~))
out.write_char('\n')
start = end
}
out.to_string()
}
///|
pub fn CompositionReport::to_json_string(self : CompositionReport) -> String {
"{\"length\":" +
self.length.to_string() +
",\"a\":" +
self.a.to_string() +
",\"c\":" +
self.c.to_string() +
",\"g\":" +
self.g.to_string() +
",\"t\":" +
self.t.to_string() +
",\"ambiguous\":" +
self.ambiguous.to_string() +
",\"gc_percent\":" +
self.gc_percent.to_string() +
",\"entropy\":" +
self.entropy.to_string() +
"}"
}
///|
pub fn TranslationReport::to_json_string(self : TranslationReport) -> String {
"{\"protein\":" +
json_string(self.protein) +
",\"codons\":" +
self.codons.to_string() +
",\"stops\":" +
self.stops.to_string() +
",\"ambiguous\":" +
self.ambiguous.to_string() +
",\"trailing_bases\":" +
self.trailing_bases.to_string() +
"}"
}