diff --git a/pkg/obidefault/progressbar.go b/pkg/obidefault/progressbar.go index 3bce334..d752525 100644 --- a/pkg/obidefault/progressbar.go +++ b/pkg/obidefault/progressbar.go @@ -1,5 +1,7 @@ package obidefault +import "os" + var __no_progress_bar__ = false func ProgressBar() bool { @@ -17,3 +19,22 @@ func SetNoProgressBar(b bool) { func NoProgressBarPtr() *bool { return &__no_progress_bar__ } + +// ProgressBarVisible tells whether a progress bar can be displayed: it is not +// disabled by --no-progressbar, the standard error output is a terminal and +// the standard output is not piped to another command. +func ProgressBarVisible() bool { + if !ProgressBar() { + return false + } + + if e, err := os.Stderr.Stat(); err != nil || (e.Mode()&os.ModeCharDevice) != os.ModeCharDevice { + return false + } + + if o, err := os.Stdout.Stat(); err == nil && (o.Mode()&os.ModeNamedPipe) == os.ModeNamedPipe { + return false + } + + return true +} diff --git a/pkg/obigraph/clique.go b/pkg/obigraph/clique.go index 8572dc0..3297f18 100644 --- a/pkg/obigraph/clique.go +++ b/pkg/obigraph/clique.go @@ -21,6 +21,10 @@ type CliqueOptions struct { Workers int // Seed seeds the random vertex orders of the restarts. Seed int64 + // Progress, if set, is called with the size of the best clique each time a + // larger one is found. Calls are serialized and made while a lock is held: + // it must return quickly and must not call FindClique. + Progress func(best int) } // CliqueResult is the outcome of FindClique. @@ -68,6 +72,9 @@ func FindClique(ctx context.Context, n int, filter NeighborFilter, target int, o mutex.Lock() if len(clique) > len(best) { best = append(best[:0], clique...) + if opts.Progress != nil { + opts.Progress(len(best)) + } } mutex.Unlock() } diff --git a/pkg/obigraph/clique_test.go b/pkg/obigraph/clique_test.go index b2c6cd2..0d9f0ad 100644 --- a/pkg/obigraph/clique_test.go +++ b/pkg/obigraph/clique_test.go @@ -141,3 +141,23 @@ func TestFindCliqueTimeout(t *testing.T) { t.Fatalf("invalid best clique %v", r.Clique) } } + +func TestFindCliqueProgress(t *testing.T) { + adj := randomGraph(rand.New(rand.NewSource(4)), 40, 0.7) + + var seen []int + r := FindClique(context.Background(), 40, matrixFilter(adj), 5, CliqueOptions{ + Progress: func(best int) { seen = append(seen, best) }, + }) + if !r.Reached { + t.Fatalf("size 5 should be reached: %+v", r) + } + if len(seen) == 0 || seen[len(seen)-1] != len(r.Clique) { + t.Fatalf("progress %v does not end at the clique size %d", seen, len(r.Clique)) + } + for i := 1; i < len(seen); i++ { + if seen[i] <= seen[i-1] { + t.Fatalf("progress must be strictly increasing: %v", seen) + } + } +} diff --git a/pkg/obitools/obioligotag/obioligotag.go b/pkg/obitools/obioligotag/obioligotag.go index bb3e93b..bd6dbd7 100644 --- a/pkg/obitools/obioligotag/obioligotag.go +++ b/pkg/obitools/obioligotag/obioligotag.go @@ -163,8 +163,14 @@ func CLIOligoFamily() ([]string, error) { } filter := obikmer.NewKmerDissimilarityFilter(kmers, size, metric, dmin) + progress := startCliqueProgress(target) result := obigraph.FindClique(ctx, len(kmers), filter, target, - obigraph.CliqueOptions{Workers: obidefault.ParallelWorkers(), Seed: 1}) + obigraph.CliqueOptions{ + Workers: obidefault.ParallelWorkers(), + Seed: 1, + Progress: progress.Update, + }) + progress.Stop(result.Reached) switch { case result.Reached: diff --git a/pkg/obitools/obioligotag/progress.go b/pkg/obitools/obioligotag/progress.go new file mode 100644 index 0000000..bcf5208 --- /dev/null +++ b/pkg/obitools/obioligotag/progress.go @@ -0,0 +1,86 @@ +package obioligotag + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/schollz/progressbar/v3" + + "git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obidefault" +) + +// cliqueProgress displays the progress of the search of a family: the bar goes +// from 0 to the required family size and follows the size of the best family +// found so far. A nil *cliqueProgress does nothing, which is what is used when +// no progress bar has to be displayed. +type cliqueProgress struct { + bar *progressbar.ProgressBar + max int + stop chan struct{} + done sync.WaitGroup +} + +// startCliqueProgress starts the progress bar of a search for a family of +// target oligonucleotides, or returns nil if no bar can be displayed. +func startCliqueProgress(target int) *cliqueProgress { + if !obidefault.ProgressBarVisible() { + return nil + } + + p := &cliqueProgress{ + max: target, + stop: make(chan struct{}), + bar: progressbar.NewOptions(target, + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionSetWidth(15), + progressbar.OptionShowCount(), + progressbar.OptionSetElapsedTime(true), + progressbar.OptionSetPredictTime(false), + progressbar.OptionThrottle(100*time.Millisecond), + progressbar.OptionSetDescription("[Searching a family of oligonucleotides]"), + ), + } + + // The search may stay a long time on the same family: keep the elapsed + // time alive. + p.done.Add(1) + go func() { + defer p.done.Done() + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + p.bar.Add(0) + case <-p.stop: + return + } + } + }() + + return p +} + +// Update reports the size of the best family found so far. +func (p *cliqueProgress) Update(best int) { + if p != nil { + p.bar.Set(min(best, p.max)) + } +} + +// Stop ends the display. The bar is filled only if the family was found. +func (p *cliqueProgress) Stop(reached bool) { + if p == nil { + return + } + close(p.stop) + p.done.Wait() + if reached { + p.bar.Finish() + } else { + p.bar.Exit() + } + fmt.Fprintln(os.Stderr) +}