mirror of
https://github.com/metabarcoding/obitools4.git
synced 2026-11-09 09:24:41 +00:00
This update introduces new configuration options for pair selection, including multi-match filtering, circularity settings, and reference site validation. It also enhances the search functionality to correctly identify and track repeated sequences and reference locations, improving the accuracy of primer pair identification.
284 lines
8.6 KiB
Go
284 lines
8.6 KiB
Go
package obiprimer
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obidefault"
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obikmer"
|
|
)
|
|
|
|
// PairParams holds the parameters of the primer pair search, named after the
|
|
// ecoPrimers options.
|
|
type PairParams struct {
|
|
PrimerLength int // -O, word size
|
|
MinLength int // -l: the amplicon (primers excluded) must be longer than this
|
|
MaxLength int // -L: and shorter than this
|
|
ThreePrime int // -3: number of 3' bases that must match exactly (0 = no constraint)
|
|
Sensitivity float64 // -s: a primer must hit more than this fraction of the examples (0.9)
|
|
FalsePositive float64 // -x: and less than this fraction of the counterexamples (0.1)
|
|
MinSpecificity float64 // -T: pairs with a lower Bs are dropped (0.6)
|
|
Circular bool // -c
|
|
Reference int // -R: corpus index of the reference sequence (used when HasReference)
|
|
HasReference bool
|
|
}
|
|
|
|
func (p PairParams) validate() error {
|
|
switch {
|
|
case p.PrimerLength < 2 || p.PrimerLength > 31:
|
|
return fmt.Errorf("obiprimer: primer length must be between 2 and 31, got %d", p.PrimerLength)
|
|
case p.MaxLength <= p.MinLength+1:
|
|
return fmt.Errorf("obiprimer: empty amplicon length range ]%d,%d[", p.MinLength, p.MaxLength)
|
|
case p.ThreePrime < 0 || p.ThreePrime > p.PrimerLength:
|
|
return fmt.Errorf("obiprimer: 3' strict bases must be in [0,%d], got %d", p.PrimerLength, p.ThreePrime)
|
|
case p.Sensitivity <= 0 || p.Sensitivity > 1 || p.FalsePositive < 0 || p.FalsePositive > 1:
|
|
return fmt.Errorf("obiprimer: quorums must be in ]0,1] and [0,1]")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Primer is a conserved word: one found, with at most the allowed errors, in
|
|
// enough example sequences to be a candidate primer.
|
|
type Primer struct {
|
|
Word uint64 // canonical word
|
|
Examples int // example sequences where it is found (either strand)
|
|
CounterExamples int
|
|
// Good means conserved in the examples and rare enough in the
|
|
// counterexamples; a pair needs at least one good primer.
|
|
Good bool
|
|
}
|
|
|
|
// seqMatch is one occurrence of a primer in one sequence.
|
|
type seqMatch struct {
|
|
primer int32
|
|
pos uint32
|
|
reverse bool
|
|
}
|
|
|
|
// primerSet is the conserved words and, for each corpus sequence, their
|
|
// occurrences sorted by position.
|
|
type primerSet struct {
|
|
primers []Primer
|
|
perSeq [][]seqMatch
|
|
}
|
|
|
|
// quorumCount is floor(n*fraction), robust to the binary form of the fraction.
|
|
func quorumCount(n int, fraction float64) int { return MinSequences(n, fraction) }
|
|
|
|
// selectPrimers keeps the words found in at least floor(examples*Sensitivity)
|
|
// example sequences and marks as good those found in at most
|
|
// floor(counterexamples*FalsePositive) counterexamples.
|
|
func selectPrimers(c *Corpus, wm []WordMatches, p PairParams) *primerSet {
|
|
counts := c.Counts()
|
|
inQ := quorumCount(counts.Examples, p.Sensitivity)
|
|
outQ := quorumCount(counts.CounterExamples, p.FalsePositive)
|
|
|
|
ps := &primerSet{perSeq: make([][]seqMatch, len(c.Seqs))}
|
|
for _, w := range wm {
|
|
ex, co := w.SequencesMatched(c)
|
|
if ex < inQ {
|
|
continue
|
|
}
|
|
id := int32(len(ps.primers))
|
|
ps.primers = append(ps.primers, Primer{w.Word, ex, co, co <= outQ})
|
|
for _, m := range w.Matches {
|
|
ps.perSeq[m.Seq] = append(ps.perSeq[m.Seq], seqMatch{id, m.Pos, m.Reverse})
|
|
}
|
|
}
|
|
for _, ms := range ps.perSeq {
|
|
slices.SortStableFunc(ms, func(a, b seqMatch) int { return cmp.Compare(a.pos, b.pos) })
|
|
}
|
|
return ps
|
|
}
|
|
|
|
// pairKey identifies a pair: the two primers, ordered by canonical word, and
|
|
// for each whether the primer is the canonical word as is (true) or its reverse
|
|
// complement (false). It is the key of ecoPrimers' pair tree.
|
|
type pairKey struct {
|
|
p1, p2 int32
|
|
a1, a2 bool
|
|
}
|
|
|
|
// occurrence is one amplicon: a pair of matches in a sequence.
|
|
type occurrence struct {
|
|
key pairKey
|
|
swapped bool // true when the upstream match is the second primer of the key
|
|
up, down seqMatch
|
|
length int // amplicon length, primers excluded
|
|
}
|
|
|
|
// forEachOccurrence calls fn for every amplicon of sequence s, applying the
|
|
// length, good-primer and 3' constraints of ecoPrimers.
|
|
func (ps *primerSet) forEachOccurrence(c *Corpus, s int, p PairParams, fn func(occurrence)) {
|
|
ms := ps.perSeq[s]
|
|
lp := p.PrimerLength
|
|
var raw []byte
|
|
if p.ThreePrime > 0 {
|
|
raw = c.Seqs[s].Sequence()
|
|
}
|
|
|
|
for i := range ms {
|
|
for j := i + 1; j < len(ms); j++ {
|
|
dpos := int(ms[j].pos) - int(ms[i].pos)
|
|
if dpos <= lp {
|
|
continue
|
|
}
|
|
length := dpos - lp
|
|
if length >= p.MaxLength {
|
|
break
|
|
}
|
|
up, down := ms[i], ms[j]
|
|
if length <= p.MinLength || !(ps.primers[up.primer].Good || ps.primers[down.primer].Good) {
|
|
continue
|
|
}
|
|
|
|
// The upstream match is read as is (primer 1 of the pair when its word
|
|
// is smaller), the downstream one as the reverse primer.
|
|
key := pairKey{up.primer, down.primer, !up.reverse, down.reverse}
|
|
swapped := !(ps.primers[key.p2].Word > ps.primers[key.p1].Word)
|
|
if swapped {
|
|
key = pairKey{key.p2, key.p1, key.a2, key.a1}
|
|
}
|
|
|
|
if p.ThreePrime > 0 && !ps.threePrimeMatches(raw, up, down, p) {
|
|
continue
|
|
}
|
|
fn(occurrence{key, swapped, up, down, length})
|
|
}
|
|
}
|
|
}
|
|
|
|
// wordBase returns base t (0 = 5') of a 2-bit packed word of size lp.
|
|
func wordBase(word uint64, lp, t int) byte { return byte(word>>(2*uint(lp-1-t))) & 3 }
|
|
|
|
// threePrimeMatches reports whether the last p.ThreePrime bases of the forward
|
|
// primer (as found at the upstream match) and of the reverse primer (reverse
|
|
// complement of the downstream window) equal the sequence, i.e. have no
|
|
// mismatch at their 3' end.
|
|
func (ps *primerSet) threePrimeMatches(raw []byte, up, down seqMatch, p PairParams) bool {
|
|
lp, n := p.PrimerLength, len(raw)
|
|
at := func(pos int) byte {
|
|
if p.Circular {
|
|
pos %= n
|
|
}
|
|
return baseCode[raw[pos]]
|
|
}
|
|
|
|
fw := ps.primers[up.primer].Word
|
|
rv := ps.primers[down.primer].Word
|
|
for t := lp - p.ThreePrime; t < lp; t++ {
|
|
// forward primer: the word when found direct, its reverse complement otherwise
|
|
var f byte
|
|
if !up.reverse {
|
|
f = wordBase(fw, lp, t)
|
|
} else {
|
|
f = 3 - wordBase(fw, lp, lp-1-t)
|
|
}
|
|
if s := at(int(up.pos) + t); s > 3 || s != f {
|
|
return false
|
|
}
|
|
|
|
// reverse primer: the word when found reverse, its reverse complement otherwise
|
|
var r byte
|
|
if down.reverse {
|
|
r = wordBase(rv, lp, t)
|
|
} else {
|
|
r = 3 - wordBase(rv, lp, lp-1-t)
|
|
}
|
|
if s := at(int(down.pos) + lp - 1 - t); s > 3 || 3-s != r {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// pairAcc accumulates, over the sequences, the counts ecoPrimers keeps while it
|
|
// builds the pairs.
|
|
type pairAcc struct {
|
|
examples, counters uint32
|
|
last int32 // 1 + last sequence counted, to count each sequence once
|
|
amplicons uint32
|
|
sumLen uint64
|
|
minLen, maxLen uint32
|
|
}
|
|
|
|
// countPairs enumerates the amplicons of every sequence, in parallel, and
|
|
// returns for each pair the sequences it amplifies and the length statistics
|
|
// of its example amplicons.
|
|
func (ps *primerSet) countPairs(c *Corpus, p PairParams) map[pairKey]*pairAcc {
|
|
workers := obidefault.ParallelWorkers()
|
|
partial := make([]map[pairKey]*pairAcc, workers)
|
|
|
|
jobs := make(chan int)
|
|
var wg sync.WaitGroup
|
|
for w := range workers {
|
|
wg.Add(1)
|
|
partial[w] = map[pairKey]*pairAcc{}
|
|
go func() {
|
|
defer wg.Done()
|
|
acc := partial[w]
|
|
for s := range jobs {
|
|
isExample := c.Roles[s] == Example
|
|
ps.forEachOccurrence(c, s, p, func(o occurrence) {
|
|
a := acc[o.key]
|
|
if a == nil {
|
|
a = &pairAcc{minLen: ^uint32(0)}
|
|
acc[o.key] = a
|
|
}
|
|
if a.last != int32(s)+1 {
|
|
a.last = int32(s) + 1
|
|
if isExample {
|
|
a.examples++
|
|
} else {
|
|
a.counters++
|
|
}
|
|
}
|
|
if isExample {
|
|
l := uint32(o.length)
|
|
a.amplicons++
|
|
a.sumLen += uint64(l)
|
|
a.minLen = min(a.minLen, l)
|
|
a.maxLen = max(a.maxLen, l)
|
|
}
|
|
})
|
|
}
|
|
}()
|
|
}
|
|
for s := range c.Seqs {
|
|
jobs <- s
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
|
|
// A sequence is handled by one worker only, so the sequence counts add up.
|
|
total := partial[0]
|
|
for _, m := range partial[1:] {
|
|
for k, a := range m {
|
|
t := total[k]
|
|
if t == nil {
|
|
total[k] = a
|
|
continue
|
|
}
|
|
t.examples += a.examples
|
|
t.counters += a.counters
|
|
t.amplicons += a.amplicons
|
|
t.sumLen += a.sumLen
|
|
t.minLen = min(t.minLen, a.minLen)
|
|
t.maxLen = max(t.maxLen, a.maxLen)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// orientedWord returns the upper-case primer string 5'->3': the canonical word
|
|
// when direct, its reverse complement otherwise.
|
|
func orientedWord(word uint64, lp int, direct bool) string {
|
|
if !direct {
|
|
word = obikmer.ReverseComplement(word, lp)
|
|
}
|
|
return strings.ToUpper(string(obikmer.DecodeKmer(word, lp, nil)))
|
|
}
|