Introduce primer pair searching functionality

This change introduces the `obiprimers` command-line tool and a complete search pipeline for identifying primer pairs. It includes new data structures for pair evaluation, comprehensive search configuration options, and detailed CSV output handling for results.
This commit is contained in:
Eric Coissac committed 2026-11-03 22:40:27 +11:00
1 parent 1ab512ab5c
commit c156f11947
9 files changed
+655 -6

No files matched your search

+22 -5
View File
@@ -36,6 +36,14 @@ type PairStats struct {
// Score is the ranking criterion of ecoPrimers.
func (p PairStats) Score() float32 { return p.Yule * p.Bs }
// PairInfo summarizes the pair search, for reporting.
type PairInfo struct {
Primers int // conserved words kept as candidate primers
GoodPrimers int // of which good ones
CandidatePairs int // pairs delimiting an amplicon in at least one sequence
RetainedPairs int // pairs passing the sensitivity and false positive quorums, before the Bs filter
}
// ampRecord is an amplicon of a retained pair, in one sequence.
type ampRecord struct {
pair int32
@@ -78,21 +86,29 @@ func ampHash(raw []byte, from, to int, reverse bool) uint64 {
// is sorted by decreasing Yule*Bs.
//
// model computes the melting temperatures; nil leaves them NaN.
func FindPairs(c *Corpus, taxa *Taxa, wm []WordMatches, p PairParams, model *obithermo.Model) ([]PairStats, error) {
func FindPairs(c *Corpus, taxa *Taxa, wm []WordMatches, p PairParams, model *obithermo.Model) ([]PairStats, PairInfo, error) {
var info PairInfo
if err := p.validate(); err != nil {
return nil, err
return nil, info, err
}
if taxa == nil || len(taxa.Of) != len(c.Seqs) {
return nil, fmt.Errorf("obiprimer: taxa do not match the corpus")
return nil, info, fmt.Errorf("obiprimer: taxa do not match the corpus")
}
ps := selectPrimers(c, wm, p)
info.Primers = len(ps.primers)
for _, pr := range ps.primers {
if pr.Good {
info.GoodPrimers++
}
}
if len(ps.primers) == 0 {
return nil, nil
return nil, info, nil
}
// Phase 1: which pairs amplify enough examples and few enough counterexamples.
counts := c.Counts()
acc := ps.countPairs(c, p)
info.CandidatePairs = len(acc)
sens, fpq := float32(p.Sensitivity), float32(p.FalsePositive)
var kept []pairKey
for k, a := range acc {
@@ -105,6 +121,7 @@ func FindPairs(c *Corpus, taxa *Taxa, wm []WordMatches, p PairParams, model *obi
kept = append(kept, k)
}
}
info.RetainedPairs = len(kept)
slices.SortFunc(kept, func(a, b pairKey) int {
return cmp.Or(cmp.Compare(a.p1, b.p1), cmp.Compare(a.p2, b.p2),
boolCmp(a.a1, b.a1), boolCmp(a.a2, b.a2))
@@ -131,7 +148,7 @@ func FindPairs(c *Corpus, taxa *Taxa, wm []WordMatches, p PairParams, model *obi
slices.SortFunc(out, func(a, b PairStats) int {
return cmp.Or(-cmp.Compare(a.Score(), b.Score()), cmp.Compare(a.Primer1, b.Primer1), cmp.Compare(a.Primer2, b.Primer2))
})
return out, nil
return out, info, nil
}
func boolCmp(a, b bool) int {
+1 -1
View File
@@ -141,7 +141,7 @@ func TestPairsAgainstEcoPrimers(t *testing.T) {
t.Fatal(err)
}
lap("approximate search")
got, err := FindPairs(corpus, taxa, wm, PairParams{
got, _, err := FindPairs(corpus, taxa, wm, PairParams{
PrimerLength: 18, MinLength: 30, MaxLength: 150, ThreePrime: 2,
Sensitivity: 0.9, FalsePositive: 0.1, MinSpecificity: 0.6, Circular: true,
}, obithermo.DefaultModel())
+91
View File
@@ -0,0 +1,91 @@
package obiprimer
import (
"fmt"
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obithermo"
)
// SearchParams gathers every parameter of the primer pair search, named after
// the ecoPrimers options they come from.
type SearchParams struct {
PrimerLength int // -O: size of the primers (default 18)
MaxErrors int // -e: substitutions allowed between a primer and its sites (default 3)
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
StrictQuorum float64 // -q: fraction of the examples containing the exact word (0.7)
Sensitivity float64 // -s: fraction of the examples a primer must match (0.9)
FalsePositive float64 // -x: fraction of the counterexamples it may match (0.1)
MinSpecificity float64 // -T: pairs with a lower Bs are dropped (0.6)
Circular bool // -c: sequences are circular
Partitions int // partitions of the k-mer counter (default 256)
TmpDir string
Model *obithermo.Model // melting temperatures; nil leaves them NaN
}
// DefaultSearchParams returns the ecoPrimers defaults.
func DefaultSearchParams() SearchParams {
return SearchParams{
PrimerLength: 18, MaxErrors: 3, MinLength: 0, MaxLength: 1000,
StrictQuorum: 0.7, Sensitivity: 0.9, FalsePositive: 0.1, MinSpecificity: 0.6,
Partitions: 256, Model: obithermo.DefaultModel(),
}
}
// SearchInfo reports the size of every stage of a search.
type SearchInfo struct {
Counts Counts
StrictWords int // words found without error in at least StrictQuorum of the examples
MinStrict int // that quorum, in sequences
Matches int // approximate occurrences of those words
Pair PairInfo
}
// Search runs the whole ecoPrimers pipeline on a corpus: strict words,
// approximate occurrences, pairs and their indices. The pairs are sorted by
// decreasing Yule*Bs.
func Search(c *Corpus, taxa *Taxa, p SearchParams) ([]PairStats, *SearchInfo, error) {
if p.MaxErrors < 0 {
return nil, nil, fmt.Errorf("obiprimer: the allowed mismatch count cannot be negative")
}
sw, err := FindStrictWords(c, WordSearch{
PrimerLength: p.PrimerLength,
Quorum: p.StrictQuorum,
Circular: p.Circular,
Partitions: p.Partitions,
TmpDir: p.TmpDir,
})
if err != nil {
return nil, nil, err
}
info := &SearchInfo{Counts: sw.Counts, StrictWords: len(sw.Words), MinStrict: sw.MinSequences}
words := make([]uint64, len(sw.Words))
for i, w := range sw.Words {
words[i] = w.Kmer
}
wm, err := MatchWords(c, words, MatchParams{K: p.PrimerLength, MaxErrors: p.MaxErrors, Circular: p.Circular})
if err != nil {
return nil, info, err
}
for _, w := range wm {
info.Matches += len(w.Matches)
}
pairs, pinfo, err := FindPairs(c, taxa, wm, PairParams{
PrimerLength: p.PrimerLength,
MinLength: p.MinLength,
MaxLength: p.MaxLength,
ThreePrime: p.ThreePrime,
Sensitivity: p.Sensitivity,
FalsePositive: p.FalsePositive,
MinSpecificity: p.MinSpecificity,
Circular: p.Circular,
}, p.Model)
info.Pair = pinfo
return pairs, info, err
}