Files
obitools4/pkg/obiprimer/words.go
T
Eric Coissac 56f5fe33d7 Implement strict word finding and sequence filtering
This introduces new structures for defining sequence roles and constraints, core logic for strict word identification, and comprehensive unit tests to ensure accuracy and correct behavior.
2026-11-03 22:34:10 +11:00

119 lines
3.6 KiB
Go

package obiprimer
import (
"fmt"
"math"
"sync"
"sync/atomic"
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obidefault"
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obiiter"
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obikmer"
)
// WordSearch holds the parameters of the strict word search.
type WordSearch struct {
PrimerLength int // word size (ecoPrimers -O, 18 by default; 2..31)
Quorum float64 // fraction of example sequences that must contain the word (ecoPrimers -q, 0.7)
Circular bool // sequences are circular (ecoPrimers -c)
Partitions int // partition files of the counter (default 256)
TmpDir string // parent of the temporary directory ("" = system default)
}
// Counts gives the number of sequences by role.
type Counts struct {
Examples int
CounterExamples int
Ignored int
}
// StrictWords is the result of the strict word search: the canonical words of
// PrimerLength bases present, without any error, in at least MinSequences
// example sequences.
type StrictWords struct {
PrimerLength int
MinSequences int // quorum in number of example sequences
Words []obikmer.SeqKmerCount
Counts Counts
}
// MinSequences returns the quorum in sequences: floor(examples * quorum).
// The tiny epsilon makes exact products (10 * 0.7 = 7) robust to the binary
// representation of the fraction.
func MinSequences(examples int, quorum float64) int {
return int(math.Floor(float64(examples)*quorum + 1e-9))
}
// FindStrictWords reads every sequence of seqs once, indexes the example
// sequences, and returns the words reaching the quorum. Sequences no longer
// than the word size are counted as examples but cannot contain any word, as
// in ecoPrimers. Words are canonical (the smaller of the word and its reverse
// complement), so both strands are searched.
func FindStrictWords(seqs obiiter.IBioSequence, sel Selection, cfg WordSearch) (*StrictWords, error) {
if cfg.PrimerLength < 2 || cfg.PrimerLength > 31 {
return nil, fmt.Errorf("obiprimer: primer length must be between 2 and 31, got %d", cfg.PrimerLength)
}
if cfg.Quorum <= 0 || cfg.Quorum > 1 {
return nil, fmt.Errorf("obiprimer: quorum must be in ]0,1], got %g", cfg.Quorum)
}
if cfg.Partitions <= 0 {
cfg.Partitions = 256
}
counter, err := obikmer.NewSeqKmerCounter(cfg.TmpDir, cfg.PrimerLength, 0, cfg.Partitions)
if err != nil {
return nil, err
}
defer counter.Close()
var nextID atomic.Uint32
var examples, counters, ignored atomic.Int64
consumer := func(it obiiter.IBioSequence, wg *sync.WaitGroup) {
defer wg.Done()
for it.Next() {
for _, seq := range it.Get().Slice() {
switch sel.Role(seq) {
case Example:
examples.Add(1)
if seq.Len() > cfg.PrimerLength {
counter.AddSequence(nextID.Add(1)-1, seq.Sequence(), cfg.Circular)
}
case CounterExample:
counters.Add(1)
default:
ignored.Add(1)
}
}
}
}
var wg sync.WaitGroup
for i := 1; i < obidefault.ParallelWorkers(); i++ {
wg.Add(1)
go consumer(seqs.Split(), &wg)
}
wg.Add(1)
go consumer(seqs, &wg)
wg.Wait()
res := &StrictWords{
PrimerLength: cfg.PrimerLength,
Counts: Counts{
Examples: int(examples.Load()),
CounterExamples: int(counters.Load()),
Ignored: int(ignored.Load()),
},
}
res.MinSequences = MinSequences(res.Counts.Examples, cfg.Quorum)
if res.Counts.Examples == 0 {
return nil, fmt.Errorf("obiprimer: no example sequence selected")
}
res.Words, err = counter.CountAtLeast(max(res.MinSequences, 1))
if err != nil {
return nil, err
}
return res, nil
}