mirror of
https://github.com/metabarcoding/obitools4.git
synced 2026-11-09 09:24:41 +00:00
This commit introduces the core matching functionality, including parallel corpus loading, the `Corpus` data structure, and implementations for `matchNaive` and `matchApat`. It also defines new matching rules, variant handling, and comprehensive unit and integration tests to validate the new logic and parameter handling.
157 lines
4.2 KiB
Go
157 lines
4.2 KiB
Go
package obiprimer
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obidefault"
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obikmer"
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obiseq"
|
|
)
|
|
|
|
// Match is one approximate occurrence of a word in a corpus sequence.
|
|
//
|
|
// Pos is the 0-based start of the window on the sequence as given (forward
|
|
// coordinates). When Reverse is false the window reads like the word; when it
|
|
// is true the window reads like the reverse complement of the word, i.e. the
|
|
// word occurs on the other strand. Errors is the number of substitutions.
|
|
type Match struct {
|
|
Seq uint32 // index in the Corpus
|
|
Pos uint32
|
|
Errors uint8
|
|
Reverse bool
|
|
}
|
|
|
|
// WordMatches gathers every occurrence of one canonical word, sorted by
|
|
// (Seq, Pos, Reverse).
|
|
type WordMatches struct {
|
|
Word uint64
|
|
Matches []Match
|
|
}
|
|
|
|
// MatchParams parameterizes the approximate search.
|
|
//
|
|
// Rules, those of ecoPrimers: only substitutions are allowed; a base other
|
|
// than A, C, G, T (or U, read as T) in the sequence never matches, it counts as
|
|
// one error; every window with at most MaxErrors errors is reported, windows
|
|
// overlapping each other included; on circular sequences windows may span the
|
|
// junction.
|
|
type MatchParams struct {
|
|
K int
|
|
MaxErrors int
|
|
Circular bool
|
|
}
|
|
|
|
func (p MatchParams) validate() error {
|
|
if p.K < 2 || p.K > 31 {
|
|
return fmt.Errorf("obiprimer: word size must be between 2 and 31, got %d", p.K)
|
|
}
|
|
if p.MaxErrors < 0 || p.MaxErrors >= p.K {
|
|
return fmt.Errorf("obiprimer: error count must be in [0,%d[, got %d", p.K, p.MaxErrors)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hit is a Match before its sequence index is known to be final.
|
|
type hit struct {
|
|
word int
|
|
m Match
|
|
}
|
|
|
|
// scanFunc finds every occurrence of every word, on both strands, in one
|
|
// sequence and reports them through emit.
|
|
type scanFunc func(seq *obiseq.BioSequence, emit func(word, pos, errors int, reverse bool))
|
|
|
|
// scannerMaker returns an independent scanFunc; each worker gets its own so
|
|
// that scanners can keep private buffers.
|
|
type scannerMaker func() scanFunc
|
|
|
|
// wordStrings returns, for each canonical word, the word and its reverse
|
|
// complement as lowercase strings.
|
|
func wordStrings(words []uint64, k int) (fwd, rev []string) {
|
|
fwd = make([]string, len(words))
|
|
rev = make([]string, len(words))
|
|
for i, w := range words {
|
|
fwd[i] = string(obikmer.DecodeKmer(w, k, nil))
|
|
rev[i] = string(obikmer.DecodeKmer(obikmer.ReverseComplement(w, k), k, nil))
|
|
}
|
|
return
|
|
}
|
|
|
|
// matchCorpus runs the scanners over every sequence of the corpus in parallel
|
|
// and assembles the per-word results. The output does not depend on the number
|
|
// of workers.
|
|
func matchCorpus(c *Corpus, words []uint64, mk scannerMaker) []WordMatches {
|
|
perSeq := make([][]hit, len(c.Seqs))
|
|
|
|
jobs := make(chan int)
|
|
var wg sync.WaitGroup
|
|
for range obidefault.ParallelWorkers() {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
scan := mk()
|
|
for i := range jobs {
|
|
var hits []hit
|
|
scan(c.Seqs[i], func(word, pos, errors int, reverse bool) {
|
|
hits = append(hits, hit{word, Match{uint32(i), uint32(pos), uint8(errors), reverse}})
|
|
})
|
|
perSeq[i] = hits
|
|
}
|
|
}()
|
|
}
|
|
for i := range c.Seqs {
|
|
jobs <- i
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
|
|
res := make([]WordMatches, len(words))
|
|
for i, w := range words {
|
|
res[i].Word = w
|
|
}
|
|
for _, hits := range perSeq {
|
|
for _, h := range hits {
|
|
res[h.word].Matches = append(res[h.word].Matches, h.m)
|
|
}
|
|
}
|
|
for i := range res {
|
|
slices.SortFunc(res[i].Matches, func(a, b Match) int {
|
|
if c := cmp.Compare(a.Seq, b.Seq); c != 0 {
|
|
return c
|
|
}
|
|
if c := cmp.Compare(a.Pos, b.Pos); c != 0 {
|
|
return c
|
|
}
|
|
if a.Reverse != b.Reverse {
|
|
if !a.Reverse {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
}
|
|
return res
|
|
}
|
|
|
|
// SequencesMatched returns, for the matches of one word, the number of
|
|
// distinct example and counterexample sequences it occurs in.
|
|
func (w WordMatches) SequencesMatched(c *Corpus) (examples, counterExamples int) {
|
|
last := ^uint32(0)
|
|
for _, m := range w.Matches {
|
|
if m.Seq == last {
|
|
continue
|
|
}
|
|
last = m.Seq
|
|
if c.Roles[m.Seq] == Example {
|
|
examples++
|
|
} else {
|
|
counterExamples++
|
|
}
|
|
}
|
|
return
|
|
}
|