Files
obitools4/pkg/obiprimer/search_real_test.go
T
Eric Coissac 57c2517b24 Implement advanced primer search filtering and reference handling
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.
2026-11-03 22:43:50 +11:00

176 lines
5.1 KiB
Go

package obiprimer
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obiformats"
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obitax"
)
// loadTutorial reads the Cyprinidae examples of the tutorial data (the opt-in
// tests need OBIPRIMERS_TUTORIAL, see TestStrictWordsAgainstEcoPrimers).
func loadTutorial(t *testing.T) (*Corpus, *Taxa, *obitax.Taxonomy) {
t.Helper()
dir := os.Getenv("OBIPRIMERS_TUTORIAL")
if dir == "" {
t.Skip("OBIPRIMERS_TUTORIAL not set")
}
taxonomy, err := obiformats.LoadTaxonomy(filepath.Join(dir, "ncbitaxo_20250211.tgz"), false, false)
if err != nil {
t.Fatal(err)
}
restrict, err := BelongingTo(taxonomy, []string{"7953"})
if err != nil {
t.Fatal(err)
}
seqs, err := obiformats.ReadSequencesFromFile(filepath.Join(dir, "mito.vert.fasta.gz"))
if err != nil {
t.Fatal(err)
}
corpus, err := LoadCorpus(seqs, Selection{Restrict: restrict})
if err != nil {
t.Fatal(err)
}
taxa, err := corpus.AssignTaxa(taxonomy, "species")
if err != nil {
t.Fatal(err)
}
return corpus, taxa, taxonomy
}
func tutorialParams(t *testing.T) SearchParams {
p := DefaultSearchParams()
p.MinLength, p.MaxLength, p.ThreePrime, p.Circular = 30, 150, 2, true
p.TmpDir = t.TempDir()
return p
}
// ecoPrimers -U on the Cyprinidae tutorial case (-e 3 -3 2 -l 30 -L 150 -c -r 7953 -U)
// keeps 1366 pairs. testdata/ecoprimers_Cyprinidae_U.pairs.tsv holds, for each,
// the primers, the amplified example and counterexample sequences, the amplified
// example and counterexample taxa, and the identified taxa.
func TestNoMultiMatchAgainstEcoPrimers(t *testing.T) {
corpus, taxa, _ := loadTutorial(t)
p := tutorialParams(t)
p.NoMultiMatch = true
pairs, info, err := Search(corpus, taxa, p)
if err != nil {
t.Fatal(err)
}
t.Logf("%+v", *info)
f, err := os.Open(filepath.Join("testdata", "ecoprimers_Cyprinidae_U.pairs.tsv"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
want := map[string][5]int{}
sc := bufio.NewScanner(f)
for sc.Scan() {
var p1, p2 string
var v [5]int
if _, err := fmt.Sscanf(sc.Text(), "%s\t%s\t%d\t%d\t%d\t%d\t%d", &p1, &p2, &v[0], &v[1], &v[2], &v[3], &v[4]); err != nil {
t.Fatal(err)
}
want[p1+"/"+p2] = v
}
if len(pairs) != len(want) {
t.Errorf("%d pairs, ecoPrimers -U reports %d", len(pairs), len(want))
}
bad := 0
for _, g := range pairs {
v, ok := want[g.Primer1+"/"+g.Primer2]
if !ok {
t.Errorf("pair %s/%s is not in the ecoPrimers -U result", g.Primer1, g.Primer2)
continue
}
got := [5]int{g.Examples, g.CounterExamples, g.ExampleTaxa, g.CounterTaxa, g.Identified}
if got != v {
if bad++; bad <= 3 {
t.Errorf("pair %s/%s: got %v, ecoPrimers %v", g.Primer1, g.Primer2, got, v)
}
}
}
}
var locRE = regexp.MustCompile(`(\d+)\.\.(\d+)`)
// ecoPrimers -R is only right for the references that happen to come first: it
// indexes the amplicons of a pair by sequence number. On NC_083213 only 382 of
// its 3405 locations are real primer sites, against all 3405 for obiprimers.
// Here the locations are checked against the sequence itself, so no
// ecoPrimers output is needed: each location must give two real primer sites
// (at most 3 substitutions) and the amplicon must be the region between them,
// read from the first primer.
func TestReferenceSitesAreRealSites(t *testing.T) {
corpus, taxa, _ := loadTutorial(t)
for _, ref := range []string{"NC_022183", "NC_083213"} {
t.Run(ref, func(t *testing.T) {
p := tutorialParams(t)
p.Reference = ref
pairs, _, err := Search(corpus, taxa, p)
if err != nil {
t.Fatal(err)
}
idx, err := corpus.IndexOfExample(ref)
if err != nil {
t.Fatal(err)
}
seq := strings.ToLower(string(corpus.Seqs[idx].Sequence()))
hamming := func(a, b string) int {
d := 0
for i := range a {
if a[i] != b[i] {
d++
}
}
return d
}
located, bad := 0, 0
for _, pr := range pairs {
if pr.RefLocation == "" {
continue
}
located++
m := locRE.FindAllStringSubmatch(pr.RefLocation, -1)
if len(m) != 2 {
t.Fatalf("location %q", pr.RefLocation)
}
var a1, b1, a2, b2 int
fmt.Sscanf(m[0][0], "%d..%d", &a1, &b1)
fmt.Sscanf(m[1][0], "%d..%d", &a2, &b2)
left, right := seq[a1-1:b1], seq[a2-1:b2]
between := seq[b1 : a2-1]
p1, p2 := strings.ToLower(pr.Primer1), strings.ToLower(pr.Primer2)
var ok bool
if strings.HasPrefix(pr.RefLocation, "complement(") {
ok = hamming(strings.ToLower(revcomp(strings.ToUpper(right))), p1) <= 3 && hamming(left, p2) <= 3 &&
pr.RefAmplicon == strings.ToLower(revcomp(strings.ToUpper(between)))
} else {
ok = hamming(left, p1) <= 3 && hamming(strings.ToLower(revcomp(strings.ToUpper(right))), p2) <= 3 &&
pr.RefAmplicon == between
}
if !ok {
if bad++; bad <= 3 {
t.Errorf("pair %s/%s: %s is not a real location", pr.Primer1, pr.Primer2, pr.RefLocation)
}
}
}
t.Logf("%d of %d pairs amplify %s; %d wrong locations", located, len(pairs), ref, bad)
if located == 0 {
t.Error("no pair amplifies the reference")
}
})
}
}