Standard mode prints only steps, one-line results with the time since the line above, a rule closing each step with its total, warnings (!!) and errors (** ERROR:), and a summary at the end. Every log line of every module goes to <prefix>_log.txt instead, at debug level and timestamped, opened with version, build, GPU, threads, command line and the header geometry. -v puts the whole log back on the terminal. - RugnuxObserver gains OnStep / OnNote. RunPipeline posts them only on a pass that makes the result (the observer is held in steps_, nullptr on probe passes and on copies run beside the main pass); RunAllPasses opens steps for the checks between passes (rotation scale, metric symmetry, geometry walk) and notes its re-runs. OnPhase is written to the log as a marker. - RugnuxConsole (CLI only): on a terminal a progress bar and a spinner status line drawn in place with \r, durations only; not a terminal, plain ASCII with the time of day on every line and quarter milestones for long passes. isatty/_isatty, no environment variables. A warning repeated by later merges is shown once more on one line and then counted per step. - The CLI's former stdout blocks (space-group search, merging statistics, per-image cost, processing time, tNCS/twinning/anisotropy in --mode scale) go to the log; the calibration result block stays on the terminal as that mode's summary. The MX summary takes its rows from the report's own SUMMARY (ReportDocument::summary_rows); WriteResultReport returns the document it rendered. HDF5's automatic error-stack printing is switched off. - The banner keeps the "Version X (git abcdef" form battery.py reads off a bare invocation; battery.py's failure note now reads p_log.txt, whose lines are not wrapped. Output only: p.mtz is bit-identical on the three in-house reference sets. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
397 lines
14 KiB
C++
397 lines
14 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "RugnuxConsole.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
#include <sstream>
|
|
#include <vector>
|
|
|
|
#include <spdlog/details/os.h>
|
|
#include <spdlog/sinks/base_sink.h>
|
|
|
|
#ifdef _WIN32
|
|
#include <io.h>
|
|
#else
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
namespace {
|
|
// Where the notes, warnings and the closing rule of a step start.
|
|
const std::string INDENT(8, ' ');
|
|
|
|
bool StdoutIsTerminal() {
|
|
#ifdef _WIN32
|
|
return _isatty(_fileno(stdout)) != 0;
|
|
#else
|
|
return isatty(fileno(stdout)) != 0;
|
|
#endif
|
|
}
|
|
|
|
// hh:mm:ss.mmm, the same clock and precision as the log file's timestamps.
|
|
std::string TimeOfDay() {
|
|
const auto now = std::chrono::system_clock::now();
|
|
const std::tm tm = spdlog::details::os::localtime(std::chrono::system_clock::to_time_t(now));
|
|
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count() % 1000;
|
|
return fmt::format("{:02}:{:02}:{:02}.{:03}", tm.tm_hour, tm.tm_min, tm.tm_sec, ms);
|
|
}
|
|
|
|
std::string Duration(double seconds) {
|
|
return fmt::format("{:.2f} s", seconds);
|
|
}
|
|
|
|
// Text set right-aligned at the console width after `left`, with at least two spaces between.
|
|
std::string RightAligned(const std::string &left, const std::string &right) {
|
|
const int pad = RugnuxConsole::WIDTH - static_cast<int>(left.size() + right.size());
|
|
return left + std::string(std::max(2, pad), ' ') + right;
|
|
}
|
|
|
|
std::string Eta(double seconds) {
|
|
if (seconds < 60.0)
|
|
return fmt::format("{:.0f} s", seconds);
|
|
const int s = static_cast<int>(seconds + 0.5);
|
|
return fmt::format("{}:{:02} min", s / 60, s % 60);
|
|
}
|
|
|
|
class ConsoleSink : public spdlog::sinks::base_sink<std::mutex> {
|
|
std::shared_ptr<RugnuxConsole> console;
|
|
public:
|
|
explicit ConsoleSink(std::shared_ptr<RugnuxConsole> c) : console(std::move(c)) {}
|
|
protected:
|
|
void sink_it_(const spdlog::details::log_msg &msg) override {
|
|
if (msg.level >= spdlog::level::warn)
|
|
console->Message(msg.level, std::string(msg.payload.data(), msg.payload.size()));
|
|
else
|
|
console->Heartbeat();
|
|
}
|
|
void flush_() override { std::fflush(stdout); }
|
|
};
|
|
}
|
|
|
|
spdlog::sink_ptr MakeConsoleSink(std::shared_ptr<RugnuxConsole> console) {
|
|
return std::make_shared<ConsoleSink>(std::move(console));
|
|
}
|
|
|
|
RugnuxConsole::RugnuxConsole(bool verbose) : verbose_(verbose), terminal_(StdoutIsTerminal()) {}
|
|
|
|
double RugnuxConsole::Elapsed() const {
|
|
return Seconds(Clock::now() - start_);
|
|
}
|
|
|
|
void RugnuxConsole::LogLine(const std::string &text) {
|
|
Logger("rugnux").Info("{}", text);
|
|
}
|
|
|
|
void RugnuxConsole::ClearStatus() {
|
|
if (!status_shown_)
|
|
return;
|
|
std::fputs(("\r" + std::string(WIDTH, ' ') + "\r").c_str(), stdout);
|
|
status_shown_ = false;
|
|
}
|
|
|
|
void RugnuxConsole::DrawStatus() {
|
|
if (!terminal_ || verbose_ || status_.empty())
|
|
return;
|
|
std::string s = status_.substr(0, WIDTH);
|
|
s.resize(WIDTH, ' ');
|
|
std::fputs(("\r" + s).c_str(), stdout);
|
|
std::fflush(stdout);
|
|
status_shown_ = true;
|
|
}
|
|
|
|
void RugnuxConsole::Emit(const std::string &line) {
|
|
ClearStatus();
|
|
std::fputs(((terminal_ ? std::string() : TimeOfDay() + " ") + line + "\n").c_str(), stdout);
|
|
DrawStatus();
|
|
std::fflush(stdout);
|
|
}
|
|
|
|
void RugnuxConsole::Timed(const std::string &text, double seconds) {
|
|
const std::string dur = Duration(seconds);
|
|
if (text.size() + 2 + dur.size() <= static_cast<size_t>(WIDTH)) {
|
|
Emit(RightAligned(text, dur));
|
|
return;
|
|
}
|
|
// Too long for one line: wrapped under itself, the time on the last line.
|
|
const size_t indent = text.find("... ") != std::string::npos ? text.find("... ") + 4 : 0;
|
|
const size_t room = WIDTH - 2 - dur.size();
|
|
std::string line = text.substr(0, indent);
|
|
bool has_words = false;
|
|
std::istringstream words(text.substr(indent));
|
|
std::string word;
|
|
while (words >> word) {
|
|
if (has_words && line.size() + 1 + word.size() > room) {
|
|
Emit(line);
|
|
line = std::string(indent, ' ');
|
|
has_words = false;
|
|
}
|
|
line += (has_words ? " " : "") + word;
|
|
has_words = true;
|
|
}
|
|
Emit(RightAligned(line, dur));
|
|
}
|
|
|
|
void RugnuxConsole::Wrapped(const std::string &first, const std::string &rest, const std::string &text) {
|
|
std::string line = first;
|
|
bool line_has_words = false;
|
|
std::istringstream paragraphs(text);
|
|
std::string paragraph;
|
|
while (std::getline(paragraphs, paragraph)) {
|
|
std::istringstream words(paragraph);
|
|
std::string word;
|
|
while (words >> word) {
|
|
if (line_has_words && line.size() + 1 + word.size() > static_cast<size_t>(WIDTH)) {
|
|
Emit(line);
|
|
line = rest;
|
|
line_has_words = false;
|
|
}
|
|
line += (line_has_words ? " " : "") + word;
|
|
line_has_words = true;
|
|
}
|
|
if (line_has_words) {
|
|
Emit(line);
|
|
line = rest;
|
|
line_has_words = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
void RugnuxConsole::Line(const std::string &text) {
|
|
std::lock_guard lock(m_);
|
|
Emit(text);
|
|
}
|
|
|
|
void RugnuxConsole::Rule(char c) {
|
|
std::lock_guard lock(m_);
|
|
Emit(std::string(WIDTH, c));
|
|
}
|
|
|
|
void RugnuxConsole::Field(const std::string &label, const std::string &value) {
|
|
std::lock_guard lock(m_);
|
|
const std::string first = fmt::format(" {:<14}", label);
|
|
if (first.size() + value.size() <= static_cast<size_t>(WIDTH) && value.find('\n') == std::string::npos)
|
|
Emit(first + value); // as it is, so the spacing inside the value is kept
|
|
else
|
|
Wrapped(first, std::string(first.size(), ' '), value);
|
|
}
|
|
|
|
void RugnuxConsole::Close(const char *how) {
|
|
if (step_ == 0)
|
|
return;
|
|
const auto now = Clock::now();
|
|
const double total = Seconds(now - step_start_);
|
|
const double rest = Seconds(now - mark_);
|
|
std::string log_text = fmt::format("Step {} {} {}", step_, how, Duration(total));
|
|
if (!verbose_) {
|
|
status_.clear();
|
|
// Time a step spent after its last note is shown where it is large, so the notes and the
|
|
// total still add up for a reader looking for where the time went.
|
|
if (notes_ > 0 && rest >= std::max(0.5, 0.1 * total))
|
|
Timed(INDENT + "... (rest of the step)", rest);
|
|
for (const auto &[kind, held] : warnings_held_) {
|
|
std::string line = fmt::format("{}!! {} more like it, the last: {}", INDENT, held.first, held.second);
|
|
if (line.size() > static_cast<size_t>(WIDTH))
|
|
line = line.substr(0, WIDTH - 3) + "...";
|
|
Emit(line);
|
|
}
|
|
const std::string label = std::string(" ") + how;
|
|
const std::string dur = Duration(total);
|
|
const int dashes = WIDTH - static_cast<int>(INDENT.size() + label.size() + 1 + dur.size());
|
|
Emit(INDENT + std::string(std::max(4, dashes), '-') + label + " " + dur);
|
|
}
|
|
pending_log_.push_back(std::move(log_text));
|
|
warnings_held_.clear();
|
|
step_ = 0;
|
|
}
|
|
|
|
void RugnuxConsole::OnStep(const std::string &title) {
|
|
{
|
|
std::lock_guard lock(m_);
|
|
const int number = std::max(step_, last_step_) + 1;
|
|
Close("done in");
|
|
step_ = last_step_ = number;
|
|
step_start_ = mark_ = Clock::now();
|
|
notes_ = 0;
|
|
if (!verbose_)
|
|
Emit(fmt::format("Step {:<2} {}", step_, title));
|
|
pending_log_.push_back(fmt::format("Step {} {}", step_, title));
|
|
}
|
|
FlushLog();
|
|
}
|
|
|
|
void RugnuxConsole::OnNote(const std::string &text) {
|
|
{
|
|
std::lock_guard lock(m_);
|
|
const auto now = Clock::now();
|
|
const double dt = Seconds(now - mark_);
|
|
if (!verbose_) {
|
|
if (step_ == 0)
|
|
Emit(INDENT + "... " + text);
|
|
else
|
|
Timed(INDENT + "... " + text, dt);
|
|
}
|
|
if (step_ > 0) {
|
|
mark_ = now;
|
|
++notes_;
|
|
}
|
|
pending_log_.push_back(fmt::format(" ... {} ({})", text, Duration(dt)));
|
|
}
|
|
FlushLog();
|
|
}
|
|
|
|
void RugnuxConsole::OnPhase(const std::string &phase) {
|
|
{
|
|
std::lock_guard lock(m_);
|
|
phase_ = phase;
|
|
pending_log_.push_back("Phase: " + phase);
|
|
}
|
|
FlushLog();
|
|
Heartbeat();
|
|
}
|
|
|
|
void RugnuxConsole::FlushLog() {
|
|
std::vector<std::string> lines;
|
|
{
|
|
std::lock_guard lock(m_);
|
|
lines.swap(pending_log_);
|
|
}
|
|
for (const auto &l : lines)
|
|
LogLine(l);
|
|
}
|
|
|
|
void RugnuxConsole::OnProgress(uint64_t done, uint64_t total) {
|
|
if (verbose_ || total == 0)
|
|
return;
|
|
const auto now = Clock::now();
|
|
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(now - start_).count();
|
|
if (done < total && now_ns < next_draw_ns_.load())
|
|
return;
|
|
next_draw_ns_ = now_ns + (terminal_ ? 100'000'000 : 250'000'000);
|
|
|
|
std::lock_guard lock(m_);
|
|
// A new loop: another total, or the count starting over after the last loop ran to its end.
|
|
// Workers report out of order, so "smaller than the last count" alone is not enough.
|
|
if (total != loop_total_ || (loop_last_ == loop_total_ && done <= loop_total_ / 2)) {
|
|
loop_total_ = total;
|
|
loop_start_ = now;
|
|
milestone_ = 0;
|
|
loop_last_ = 0;
|
|
}
|
|
loop_last_ = std::max(loop_last_, done);
|
|
const double elapsed = Seconds(now - loop_start_);
|
|
const double rate = elapsed > 0.0 ? static_cast<double>(loop_last_) / elapsed : 0.0;
|
|
|
|
if (terminal_) {
|
|
if (loop_last_ >= loop_total_) {
|
|
status_.clear();
|
|
ClearStatus();
|
|
return;
|
|
}
|
|
constexpr int BAR = 32;
|
|
const int filled = static_cast<int>(BAR * loop_last_ / loop_total_);
|
|
// The rate and what is left only once there is a second of loop to read them off.
|
|
status_ = fmt::format("{}[{}{}] {:>6}/{}{}", INDENT, std::string(filled, '#'),
|
|
std::string(BAR - filled, '.'), loop_last_, loop_total_,
|
|
rate > 0.0 && elapsed > 1.0
|
|
? fmt::format(" {:5.0f}/s ETA {}", rate,
|
|
Eta(static_cast<double>(loop_total_ - loop_last_) / rate))
|
|
: std::string());
|
|
DrawStatus();
|
|
return;
|
|
}
|
|
// Not a terminal: no bar, but a long pass says how far it has got once a quarter.
|
|
const int quarter = static_cast<int>(4 * loop_last_ / loop_total_);
|
|
while (milestone_ < std::min(quarter, 3)) {
|
|
++milestone_;
|
|
if (elapsed > 5.0)
|
|
Emit(fmt::format("{}... {}% ({} of {} images, {:.0f} per second)", INDENT, 25 * milestone_,
|
|
loop_last_, loop_total_, rate));
|
|
}
|
|
}
|
|
|
|
void RugnuxConsole::Heartbeat() {
|
|
if (!terminal_ || verbose_)
|
|
return;
|
|
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now() - start_).count();
|
|
if (now_ns < next_draw_ns_.load())
|
|
return;
|
|
// Never wait here: this runs inside every log call of every thread.
|
|
std::unique_lock lock(m_, std::try_to_lock);
|
|
if (!lock.owns_lock())
|
|
return;
|
|
next_draw_ns_ = now_ns + 250'000'000;
|
|
const bool loop_running = loop_total_ > 0 && loop_last_ < loop_total_;
|
|
if (step_ == 0 || loop_running)
|
|
return;
|
|
const double in_step = Seconds(Clock::now() - step_start_);
|
|
if (in_step < 1.0)
|
|
return;
|
|
// A turning bar and what the pipeline says it is doing, so a long step is seen to be alive.
|
|
static constexpr char SPIN[] = {'|', '/', '-', '\\'};
|
|
const std::string time = fmt::format("{:.1f} s", in_step);
|
|
std::string what = phase_.empty() ? std::string("working") : phase_;
|
|
const size_t room = WIDTH - INDENT.size() - 4 - time.size() - 3;
|
|
if (what.size() > room)
|
|
what = what.substr(0, room - 3) + "...";
|
|
status_ = RightAligned(INDENT + "[" + SPIN[spin_++ % 4] + "] " + what, time);
|
|
DrawStatus();
|
|
}
|
|
|
|
void RugnuxConsole::Message(spdlog::level::level_enum level, const std::string &text) {
|
|
std::lock_guard lock(m_);
|
|
const std::string indent = step_ > 0 ? INDENT : std::string();
|
|
const std::string tag = level >= spdlog::level::err ? "** ERROR: " : "!! ";
|
|
// A warning the run has already shown in full, raised again by a later merge or pass with other
|
|
// numbers in it, gets one line the first time it comes back and is then only counted, the count
|
|
// shown when the step ends: it is still there, and the log has every one of them whole.
|
|
std::string kind;
|
|
for (char c : text.substr(0, 48))
|
|
if (!std::isdigit(static_cast<unsigned char>(c)))
|
|
kind += c;
|
|
if (level < spdlog::level::err) {
|
|
const int seen = warnings_seen_[kind]++;
|
|
if (seen == 1) {
|
|
std::string line = indent + tag + "again: " + text;
|
|
if (line.size() > static_cast<size_t>(WIDTH))
|
|
line = line.substr(0, WIDTH - 3) + "...";
|
|
Emit(line);
|
|
}
|
|
if (seen >= 1) {
|
|
if (seen >= 2) {
|
|
++warnings_held_[kind].first;
|
|
warnings_held_[kind].second = text;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
Wrapped(indent + tag, indent + std::string(tag.size(), ' '), text);
|
|
}
|
|
|
|
void RugnuxConsole::Finish() {
|
|
{
|
|
std::lock_guard lock(m_);
|
|
Close("done in");
|
|
status_.clear();
|
|
ClearStatus();
|
|
}
|
|
FlushLog();
|
|
}
|
|
|
|
void RugnuxConsole::Fail() {
|
|
{
|
|
std::lock_guard lock(m_);
|
|
Close("FAILED after");
|
|
status_.clear();
|
|
ClearStatus();
|
|
}
|
|
FlushLog();
|
|
}
|
|
|
|
bool RugnuxConsole::StepOpen() {
|
|
std::lock_guard lock(m_);
|
|
return step_ > 0;
|
|
}
|