diff --git a/DESCRIPTION b/DESCRIPTION index c0f0bef..38098b4 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -33,6 +33,7 @@ Imports: utils Suggests: codetools, + DT, ggpubr, knitr, later, diff --git a/NEWS.md b/NEWS.md index 29e3439..88a55a8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,14 @@ # clusters (development version) +* `DEoptimDashboard()` and `DEoptimDashboardApp()` gain `initialIteration` (default `5L`): the plots start at that generation, so very poor first generations do not flatten them. + +* `DEoptimProgress()`, `DEoptimDashboard()` and `DEoptimDashboardApp()` take `which`, any of `"running"`, `"stopped"`, + `"finished"`, `"errored"` (any case; default all), to show only those fits. A fit that stops with an R error now ends its + progress file with an `ERRORED` row, and is labelled `ERRORED` instead of `STOPPED`; a killed process is still `STOPPED`. + The dashboard's table is a DT table (DT is in Suggests) that sorts by any column and keeps its order, page and search + when it refreshes. The fit cards follow the table's sort and search, and fill the window, 6 across on a wide one and one on a narrow one. A folder of + symlinks to fits under `path` no longer lists each fit twice. + * Three options now default to the values FireSense production has always set (defined once, in `.optionDefaults`); an explicit setting, including 0, still wins. `clusters.deoptimRescoreEvery` is 10: `DEoptimIterative()` re-scores the surviving population every 10 generations (was never), which converged in about half the generations and reached diff --git a/R/DEoptim.R b/R/DEoptim.R index b60d91a..a8c4495 100644 --- a/R/DEoptim.R +++ b/R/DEoptim.R @@ -309,6 +309,8 @@ DEoptimIterative <- function(fn, lower, upper, control, ..., cacheIds <- c(as.list(prevRunCacheIds), lapply(1:500, function(x) NULL)) } + ## an R error ends the file with an ERRORED row; a killed process writes nothing and stays STOPPED + withCallingHandlers({ for (iter in itersToDo) { control$itermax <- chunkLengths[iter] @@ -508,7 +510,9 @@ DEoptimIterative <- function(fn, lower, upper, control, ..., break } } - .progressAppend(progressFile, NULL, chunkEnds[length(DE)], NULL, finished = TRUE) + }, error = function(e) + .progressAppend(progressFile, NULL, max(0L, chunkEnds[seq_along(DE)]), NULL, marker = .progressErrored)) + .progressAppend(progressFile, NULL, chunkEnds[length(DE)], NULL, marker = .progressFinished) .showHostSpeed(ranEvaluations) DE } diff --git a/R/DEoptimProgress.R b/R/DEoptimProgress.R index e82e49b..ec82b72 100644 --- a/R/DEoptimProgress.R +++ b/R/DEoptimProgress.R @@ -4,6 +4,9 @@ .progressObjective <- "objective" # the `variable` of the objective's rows .progressFinished <- "FINISHED" # the `variable` of the row that ends a file, and a fit's status +.progressErrored <- "ERRORED" # the same, for a fit that stopped with an R error +.progressMarkers <- c(.progressFinished, .progressErrored) +.progressStatuses <- c("running", "stopped", "finished", "errored") # the values of `which` .progressColumns <- c("time", "generation", "variable", "best", "q10", "median", "q90") ## The file a fit writes: `progressFile` (NULL: in `figurePath`, or "." without one; FALSE: none), emptied @@ -19,14 +22,14 @@ } ## Rows for the chunk ending at `generation`: the objective, then each parameter (best member's value and -## the population's quantiles). `finished`: one FINISHED row instead. -.progressRows <- function(member, generation, parNames, finished = FALSE) { +## the population's quantiles). `marker` (.progressFinished or .progressErrored): one row of it instead. +.progressRows <- function(member, generation, parNames, marker = NULL) { row <- function(variable, best, x) data.frame( time = format(Sys.time(), "%Y-%m-%dT%H:%M:%S"), generation = generation, variable = variable, best = best, q10 = if (is.null(x)) NA_real_ else stats::quantile(x, 0.1, names = FALSE), median = if (is.null(x)) NA_real_ else stats::median(x), q90 = if (is.null(x)) NA_real_ else stats::quantile(x, 0.9, names = FALSE)) - if (finished) return(row(.progressFinished, NA_real_, NULL)) + if (!is.null(marker)) return(row(marker, NA_real_, NULL)) pop <- member$pop bestRow <- which.min(member$popval) if (length(parNames) != NCOL(pop)) parNames <- paste0("V", seq_len(NCOL(pop))) @@ -42,10 +45,10 @@ call. = FALSE)) } -## Append the rows of a chunk (or the FINISHED row) to a fit's file; a no-op when `file` is FALSE. -.progressAppend <- function(file, member, generation, parNames, finished = FALSE) { +## Append the rows of a chunk (or an end marker row) to a fit's file; a no-op when `file` is FALSE. +.progressAppend <- function(file, member, generation, parNames, marker = NULL) { if (isFALSE(file)) return(invisible()) - tryCatch(.progressWrite(file, .progressRows(member, generation, parNames, finished), append = TRUE), + tryCatch(.progressWrite(file, .progressRows(member, generation, parNames, marker), append = TRUE), error = function(e) warning("could not make the DEoptim progress rows: ", conditionMessage(e), call. = FALSE)) invisible() @@ -58,8 +61,10 @@ #' #' @param path Folder searched recursively. #' @param pattern Regular expression the file names must match. -#' @param runningWithin Seconds. A fit without a `FINISHED` row is `RUNNING` when its file changed within -#' this time, and `STOPPED` otherwise. +#' @param runningWithin Seconds. A fit without a `FINISHED` or `ERRORED` row is `RUNNING` when its file +#' changed within this time, and `STOPPED` otherwise (a killed process leaves no marker). +#' @param which Which fits to return: any of `"running"`, `"stopped"`, `"finished"`, `"errored"`, in any case. +#' The default is all of them. `gens` and `params` are cut to the same fits. #' @param state An environment that keeps what has been read, so a refresh reads only changed files. #' #' @return A list of three data.tables. `fits`: one row per file (`fit` the file, `label` its folder @@ -68,8 +73,11 @@ #' `params`: `fit`, `generation`, `param`, `best`, `q10`, `median`, `q90`. #' @export DEoptimProgress <- function(path = ".", pattern = "^DEoptimProgress_.*\\.csv$", runningWithin = 15 * 60, - state = new.env()) { + state = new.env(), which = .progressStatuses) { + which <- toupper(match.arg(tolower(which), .progressStatuses, several.ok = TRUE)) files <- list.files(path, pattern = pattern, recursive = TRUE, full.names = TRUE) + ## a folder of symlinks to fits under `path` lists each file twice; the label is the real location's + files <- files[!duplicated(normalizePath(files, mustWork = FALSE))] if (is.null(state$files)) state$files <- list() mtime <- file.mtime(files) for (i in seq_along(files)) { @@ -90,6 +98,7 @@ DEoptimProgress <- function(path = ".", pattern = "^DEoptimProgress_.*\\.csv$", fit = f, label = paste(c(if (nzchar(folder)) folder, if (runName != "1") runName), collapse = " "), status = if (any(d$variable == .progressFinished)) .progressFinished + else if (any(d$variable == .progressErrored)) .progressErrored else if (difftime(Sys.time(), state$files[[f]]$mtime, units = "secs") <= runningWithin) "RUNNING" else "STOPPED", generation = if (nrow(obj)) max(obj$generation) else NA_integer_, @@ -97,10 +106,13 @@ DEoptimProgress <- function(path = ".", pattern = "^DEoptimProgress_.*\\.csv$", started = d$time[1], updated = state$files[[f]]$mtime) })) fits[fits$label == "", "label" := "."] + fits <- fits[fits$status %in% which, ] + files <- fits$fit + if (!length(files)) return(empty) parts <- lapply(files, function(f) cbind(fit = f, state$files[[f]]$data)) rows <- data.table::rbindlist(parts) obj <- rows[rows$variable == .progressObjective, ] - par <- rows[!rows$variable %in% c(.progressObjective, .progressFinished), ] + par <- rows[!rows$variable %in% c(.progressObjective, .progressMarkers), ] list(fits = fits, gens = obj[, c("fit", "generation", "best", "q10", "median")], params = data.table::setnames(par[, c("fit", "generation", "variable", "best", "q10", "median", "q90")], @@ -129,65 +141,99 @@ DEoptimProgress <- function(path = ".", pattern = "^DEoptimProgress_.*\\.csv$", #' #' A table of the fits whose progress files ([DEoptimProgress()]) are under `path`, then a card per fit with #' its objective by generation and, in a collapsed section, its parameters by generation. It re-reads the -#' files every `refreshMinutes`. Needs the shiny package. +#' files every `refreshMinutes`. Click a column header of the table to sort by it; the sort order, page and +#' search are kept when the table refreshes. Needs the shiny and DT packages. #' #' @inheritParams DEoptimProgress #' @param refreshMinutes Minutes between reads. #' @param title Title of the page. #' @param description Text under the title. #' @param launch.browser Passed to [shiny::runApp()]. +#' @param which Which fits to show, as in [DEoptimProgress()]; the cards follow the table. +#' @param initialIteration The first generation plotted. The first generations are often so poor that +#' they flatten everything after them; a fit with fewer generations shows them all. #' #' @return `DEoptimDashboard()` runs the app. `DEoptimDashboardApp()` returns it, as a `shiny::shinyApp`. #' @export DEoptimDashboard <- function(path = ".", refreshMinutes = 2, title = "DEoptim fits", description = .dashboardDescription, - launch.browser = getOption("shiny.launch.browser", interactive())) { - shiny::runApp(DEoptimDashboardApp(path, refreshMinutes, title, description), launch.browser = launch.browser) + launch.browser = getOption("shiny.launch.browser", interactive()), + which = .progressStatuses, initialIteration = 5L) { + shiny::runApp(DEoptimDashboardApp(path, refreshMinutes, title, description, which, initialIteration), + launch.browser = launch.browser) } +## The disclosure arrow of "parameters by generation", drawn here (one in every browser: the native marker +## is hidden, since Bootstrap hides it in some). +.summaryCss <- paste( + "details > summary { list-style: none; cursor: pointer; color: #5d655f; }", + "details > summary::-webkit-details-marker { display: none; }", + "details > summary::before { content: '\\25B8\\00a0'; }", + "details[open] > summary::before { content: '\\25BE\\00a0'; }") + +.appStyle <- function() shiny::tags$style(shiny::HTML(paste(.summaryCss, .cardsCss))) + +## The cards fill the window: as many across as fit, none narrower than `.cardMinPx`, at most `.cardsMax`. +.cardMinPx <- 300L +.cardsMax <- 6L +.cardGapPx <- 12L +.cardsCss <- sprintf( + ".fit-cards { display: grid; gap: %dpx; grid-template-columns: repeat(auto-fill, minmax(max(%dpx, calc((100%% - %d * %dpx) / %d)), 1fr)); } .fit-cards > div { min-width: 0; }", + .cardGapPx, .cardMinPx, .cardsMax - 1L, .cardGapPx, .cardsMax) + .dashboardDescription <- paste( "One card per fit (DEoptim): the objective of the best member, the 10th percentile and the median of the", - "population at each generation; lower is better. The y axis stops at the 90th percentile, so the first, very", - "poor generations do not flatten it. Open 'parameters by generation' for each parameter's 10th-90th", + "population at each generation; lower is better. The plots start at generation `initialIteration` and the", + "y axis stops at the 90th percentile, so the first, very poor generations do not flatten them. Open 'parameters by generation' for each parameter's 10th-90th", "percentile band (blue), median (black) and best member (orange). Running: the fit's file changed in the", "last 15 min. Finished: the fit is done.") #' @rdname DEoptimDashboard #' @export DEoptimDashboardApp <- function(path = ".", refreshMinutes = 2, title = "DEoptim fits", - description = .dashboardDescription) { + description = .dashboardDescription, which = .progressStatuses, + initialIteration = 5L) { if (!requireNamespace("shiny", quietly = TRUE)) stop("DEoptimDashboard needs the shiny package") + if (!requireNamespace("DT", quietly = TRUE)) stop("DEoptimDashboard needs the DT package") state <- new.env() ui <- shiny::fluidPage( ## Bootstrap hides the disclosure triangle of - shiny::tags$style("details > summary { display: list-item; cursor: pointer; color: #5d655f; }"), + .appStyle(), shiny::titlePanel(title), shiny::p(description), shiny::textOutput("checked"), - shiny::tableOutput("fits"), + DT::DTOutput("fits"), shiny::uiOutput("cards")) server <- function(input, output, session) { data <- shiny::reactive({ shiny::invalidateLater(refreshMinutes * 60 * 1000) - d <- DEoptimProgress(path, state = state) + d <- DEoptimProgress(path, state = state, which = which) d$fits <- d$fits[order(d$fits$label), ] d }) output$checked <- shiny::renderText(paste("read", format(Sys.time(), "%H:%M:%S"), "- refreshes every", refreshMinutes, "min")) - output$fits <- shiny::renderTable({ - f <- data()$fits - if (!nrow(f)) return(data.frame(note = "No DEoptim progress files found.")) - data.frame(fit = f$label, status = f$status, generation = f$generation, - `best objective` = round(f$best, 1), started = format(f$started, "%m-%d %H:%M"), - updated = format(f$updated, "%m-%d %H:%M"), check.names = FALSE) + ## The table is drawn once; a refresh replaces its data in place, which keeps the sort order, the page + ## and the search (a re-render would reset them). Numbers and times stay numbers and times, so they + ## sort as such. + output$fits <- DT::renderDT(.fitsTable(shiny::isolate(data())$fits)) + proxy <- DT::dataTableProxy("fits") + shiny::observeEvent(data(), DT::replaceData(proxy, .fitsData(data()$fits), resetPaging = FALSE, + rownames = FALSE), ignoreInit = TRUE) + ## The cards follow the table: its rows in the order shown (sorted and searched), as positions in + ## the current data. Until the table reports them, or when they do not fit the data (a refresh changed + ## the fits), the default order. A click on a header redraws the cards and their plots. + cardOrder <- shiny::reactive({ + n <- nrow(data()$fits) + shown <- input$fits_rows_all + if (is.null(shown) || !all(shown %in% seq_len(n))) seq_len(n) else shown }) ## one card per fit; the parameter plot is drawn when its section is first opened (Shiny does not ## draw hidden outputs; "shown" makes it check again) output$cards <- shiny::renderUI({ f <- data()$fits - shiny::fluidRow(lapply(seq_len(nrow(f)), function(i) shiny::column( - 4, shiny::tags$h4(paste(f$label[i], "-", tolower(f$status[i]), "- generation", f$generation[i])), + shiny::div(class = "fit-cards", lapply(cardOrder(), function(i) shiny::div( + shiny::tags$h4(paste(f$label[i], "-", tolower(f$status[i]), "- generation", f$generation[i])), shiny::plotOutput(paste0("obj_", i), height = "260px"), shiny::tags$details(ontoggle = "$(this).trigger('shown')", shiny::tags$summary("parameters by generation"), @@ -195,9 +241,13 @@ DEoptimDashboardApp <- function(path = ".", refreshMinutes = 2, title = "DEoptim }) ## output ids are the card's position; the fit is looked up when drawn, so a refresh keeps the plots shiny::observe({ + cardOrder() # drawn again when the cards are for (i in seq_len(nrow(data()$fits))) local({ i <- i - ofFit <- function(x) { x <- x[x$fit == data()$fits$fit[i], ]; x[order(x$generation), ] } + ofFit <- function(x) { + x <- .fromIteration(x[x$fit == data()$fits$fit[i], ], initialIteration) + x[order(x$generation), ] + } output[[paste0("obj_", i)]] <- shiny::renderPlot(.plotObjective(ofFit(data()$gens))) output[[paste0("par_", i)]] <- shiny::renderPlot(.plotParams(ofFit(data()$params))) }) @@ -206,6 +256,26 @@ DEoptimDashboardApp <- function(path = ".", refreshMinutes = 2, title = "DEoptim shiny::shinyApp(ui, server) } +## The columns of the top table, with their types. +.fitsData <- function(f) if (!nrow(f)) data.frame( + fit = character(), status = character(), generation = integer(), `best objective` = numeric(), + started = .POSIXct(numeric()), updated = .POSIXct(numeric()), check.names = FALSE) else data.frame( + fit = f$label, status = f$status, generation = f$generation, `best objective` = round(f$best, 1), + started = f$started, updated = f$updated, check.names = FALSE) + +.fitsTable <- function(f) { + DT::formatDate(DT::datatable(.fitsData(f), rownames = FALSE, selection = "none", + options = list(pageLength = 50, language = list( + emptyTable = "No DEoptim progress files found."))), + c("started", "updated"), method = "toLocaleString") +} + +## generations from `initialIteration` on; all of them while a fit has none that late +.fromIteration <- function(x, initialIteration) { + late <- x$generation >= initialIteration + if (any(late)) x[late, ] else x +} + .plotObjective <- function(g) { if (!nrow(g)) return(invisible()) cols <- c("#b5480e", "#1d6fa5", "grey20") @@ -222,7 +292,7 @@ DEoptimDashboardApp <- function(path = ".", refreshMinutes = 2, title = "DEoptim .plotParams <- function(p) { if (!nrow(p)) return(invisible()) nms <- unique(p$param) - graphics::par(mfrow = c(ceiling(length(nms) / 2), 2), mar = c(3, 3, 2, 1), mgp = c(1.8, 0.6, 0)) + graphics::par(mfrow = c(ceiling(length(nms) / 2), 2), mar = c(2.2, 2.2, 1.4, 0.4), mgp = c(1.2, 0.4, 0), cex.axis = 0.7, cex.main = 0.8, tcl = -0.3) for (nm in nms) { q <- p[p$param == nm, ] graphics::plot(q$generation, q$median, type = "n", ylim = range(q$q10, q$q90, q$best), main = nm, diff --git a/man/DEoptimDashboard.Rd b/man/DEoptimDashboard.Rd index 7774833..a805269 100644 --- a/man/DEoptimDashboard.Rd +++ b/man/DEoptimDashboard.Rd @@ -10,14 +10,18 @@ DEoptimDashboard( refreshMinutes = 2, title = "DEoptim fits", description = .dashboardDescription, - launch.browser = getOption("shiny.launch.browser", interactive()) + launch.browser = getOption("shiny.launch.browser", interactive()), + which = .progressStatuses, + initialIteration = 5L ) DEoptimDashboardApp( path = ".", refreshMinutes = 2, title = "DEoptim fits", - description = .dashboardDescription + description = .dashboardDescription, + which = .progressStatuses, + initialIteration = 5L ) } \arguments{ @@ -30,6 +34,11 @@ DEoptimDashboardApp( \item{description}{Text under the title.} \item{launch.browser}{Passed to \code{\link[shiny:runApp]{shiny::runApp()}}.} + +\item{which}{Which fits to show, as in \code{\link[=DEoptimProgress]{DEoptimProgress()}}; the cards follow the table.} + +\item{initialIteration}{The first generation plotted. The first generations are often so poor that +they flatten everything after them; a fit with fewer generations shows them all.} } \value{ \code{DEoptimDashboard()} runs the app. \code{DEoptimDashboardApp()} returns it, as a \code{shiny::shinyApp}. @@ -37,5 +46,6 @@ DEoptimDashboardApp( \description{ A table of the fits whose progress files (\code{\link[=DEoptimProgress]{DEoptimProgress()}}) are under \code{path}, then a card per fit with its objective by generation and, in a collapsed section, its parameters by generation. It re-reads the -files every \code{refreshMinutes}. Needs the shiny package. +files every \code{refreshMinutes}. Click a column header of the table to sort by it; the sort order, page and +search are kept when the table refreshes. Needs the shiny and DT packages. } diff --git a/man/DEoptimProgress.Rd b/man/DEoptimProgress.Rd index 8702428..c55b457 100644 --- a/man/DEoptimProgress.Rd +++ b/man/DEoptimProgress.Rd @@ -8,7 +8,8 @@ DEoptimProgress( path = ".", pattern = "^DEoptimProgress_.*\\\\.csv$", runningWithin = 15 * 60, - state = new.env() + state = new.env(), + which = .progressStatuses ) } \arguments{ @@ -16,10 +17,13 @@ DEoptimProgress( \item{pattern}{Regular expression the file names must match.} -\item{runningWithin}{Seconds. A fit without a \code{FINISHED} row is \code{RUNNING} when its file changed within -this time, and \code{STOPPED} otherwise.} +\item{runningWithin}{Seconds. A fit without a \code{FINISHED} or \code{ERRORED} row is \code{RUNNING} when its file +changed within this time, and \code{STOPPED} otherwise (a killed process leaves no marker).} \item{state}{An environment that keeps what has been read, so a refresh reads only changed files.} + +\item{which}{Which fits to return: any of \code{"running"}, \code{"stopped"}, \code{"finished"}, \code{"errored"}, in any case. +The default is all of them. \code{gens} and \code{params} are cut to the same fits.} } \value{ A list of three data.tables. \code{fits}: one row per file (\code{fit} the file, \code{label} its folder diff --git a/tests/testthat/helper-progressFiles.R b/tests/testthat/helper-progressFiles.R index b2a6e88..abc7e40 100644 --- a/tests/testthat/helper-progressFiles.R +++ b/tests/testthat/helper-progressFiles.R @@ -11,3 +11,12 @@ writeProgress <- function(file, finished = FALSE, age = 0) { Sys.setFileTime(file, Sys.time() - age) file } + +## `errored`: the progress file of a fit that stopped with an R error, ended by an ERRORED row. +writeProgressErrored <- function(file, age = 0) { + writeProgress(file) + data.table::fwrite(data.frame(time = "2026-10-08T10:05:00", generation = 2, variable = "ERRORED", + best = NA, q10 = NA, median = NA, q90 = NA), file, append = TRUE) + Sys.setFileTime(file, Sys.time() - age) + file +} diff --git a/tests/testthat/test-DEoptimDashboardApp.R b/tests/testthat/test-DEoptimDashboardApp.R index 44f0492..32b8063 100644 --- a/tests/testthat/test-DEoptimDashboardApp.R +++ b/tests/testthat/test-DEoptimDashboardApp.R @@ -6,6 +6,7 @@ ## stop a worker or a connection on purpose would hang. skip_if_not_installed("shiny") +skip_if_not_installed("DT") test_that("DEoptimDashboardApp lists the fits and draws their plots", { root <- withr::local_tempdir() @@ -23,10 +24,69 @@ test_that("DEoptimDashboardApp lists the fits and draws their plots", { } environment(serve) <- globalenv() out <- parallel::clusterCall(cl, serve, root)[[1]] - expect_match(out$fits, "ELF1") - expect_match(out$fits, "ELF2") - expect_match(out$fits, "FINISHED") + expect_match(out$fits, "container") # the rows are sent to the browser on request (server-side DT) expect_match(out$checked, "refreshes every") expect_true(nzchar(out$obj1)) expect_true(nzchar(out$par2)) }) + +test_that("the top table keeps numbers and times as numbers and times, so they sort as such, also when empty", { + root <- withr::local_tempdir() + writeProgress(file.path(root, "ELF1", "DEoptimProgress_1.csv")) + writeProgressErrored(file.path(root, "ELF2", "DEoptimProgress_1.csv")) + d <- clusters:::.fitsData(DEoptimProgress(root)$fits) + expect_identical(names(d), c("fit", "status", "generation", "best objective", "started", "updated")) + expect_type(d$generation, "integer") + expect_type(d$`best objective`, "double") + expect_s3_class(d$started, "POSIXct") + expect_s3_class(d$updated, "POSIXct") + e <- clusters:::.fitsData(DEoptimProgress(withr::local_tempdir())$fits) + expect_identical(nrow(e), 0L) + expect_identical(lapply(e, class), lapply(d, class)) + expect_s3_class(clusters:::.fitsTable(DEoptimProgress(root)$fits), "datatables") + expect_s3_class(DEoptimDashboardApp(root, which = "errored"), "shiny.appobj") +}) + +test_that("the cards are a CSS grid: at most 6 across, none narrower than 300 px", { + expect_match(clusters:::.cardsCss, "display: grid", fixed = TRUE) + expect_match(clusters:::.cardsCss, "minmax(max(300px, calc((100% - 5 * 12px) / 6)), 1fr)", fixed = TRUE) +}) + +test_that("the cards follow the table's displayed order, and the disclosure arrow is in the page", { + root <- withr::local_tempdir() + for (n in c("ELF1", "ELF2", "ELF3")) writeProgress(file.path(root, n, "DEoptimProgress_1.csv")) + ui <- as.character(shiny::fluidPage(clusters:::.appStyle())) + expect_match(ui, "details[open] > summary::before { content: '\\25BE", fixed = TRUE) + expect_match(ui, "-webkit-details-marker { display: none; }", fixed = TRUE) + cl <- localTestCluster(1L) + serve <- function(root) { + res <- new.env() + shiny::testServer(clusters::DEoptimDashboardApp(root), { + titles <- function() regmatches(output$cards$html, gregexpr("ELF[0-9]", output$cards$html))[[1]] + first <- titles() + session$setInputs(fits_rows_all = c(3L, 1L, 2L)) + permuted <- titles() + session$setInputs(fits_rows_all = c(2L, 3L)) # a search that hides ELF1 + filtered <- titles() + session$setInputs(fits_rows_all = 1:7) # not this data: the default order + assign("out", envir = res, list(first = first, permuted = permuted, filtered = filtered, + stale = titles())) + }) + res$out + } + environment(serve) <- globalenv() + out <- parallel::clusterCall(cl, serve, root)[[1]] + expect_identical(out$first, c("ELF1", "ELF2", "ELF3")) + expect_identical(out$permuted, c("ELF3", "ELF1", "ELF2")) + expect_identical(out$filtered, c("ELF2", "ELF3")) + expect_identical(out$stale, c("ELF1", "ELF2", "ELF3")) +}) + +test_that("plots start at initialIteration, unless a fit has no generation that late", { + g <- data.frame(generation = 1:8, best = c(1e6, 50, 40, 30, 20, 15, 12, 10)) + expect_identical(clusters:::.fromIteration(g, 5L)$generation, 5:8) + expect_identical(clusters:::.fromIteration(g, 1L)$generation, 1:8) + expect_identical(clusters:::.fromIteration(g[1:3, ], 5L)$generation, 1:3) + expect_identical(formals(DEoptimDashboard)$initialIteration, 5L) + expect_identical(formals(DEoptimDashboardApp)$initialIteration, 5L) +}) diff --git a/tests/testthat/test-DEoptimProgress.R b/tests/testthat/test-DEoptimProgress.R index 9ed9ac2..19ca5ba 100644 --- a/tests/testthat/test-DEoptimProgress.R +++ b/tests/testthat/test-DEoptimProgress.R @@ -85,3 +85,74 @@ test_that("DEoptimProgress re-reads only files whose modification time changed", expect_identical(DEoptimProgress(root, state = state)$fits$status, "FINISHED") expect_identical(nrow(DEoptimProgress(withr::local_tempdir())$fits), 0L) }) + +fourFits <- function() { + root <- withr::local_tempdir(.local_envir = parent.frame()) + writeProgress(file.path(root, "run", "DEoptimProgress_1.csv"), age = 60) + writeProgress(file.path(root, "stop", "DEoptimProgress_1.csv"), age = 3600) + writeProgress(file.path(root, "done", "DEoptimProgress_1.csv"), finished = TRUE, age = 3600) + writeProgressErrored(file.path(root, "err", "DEoptimProgress_1.csv"), age = 60) # recent, still ERRORED + root +} + +test_that("an ERRORED row makes the status ERRORED, ahead of RUNNING and STOPPED, not a parameter", { + root <- fourFits() + p <- DEoptimProgress(root) + f <- p$fits[order(p$fits$label), ] + expect_identical(f$label, c("done", "err", "run", "stop")) + expect_identical(f$status, c("FINISHED", "ERRORED", "RUNNING", "STOPPED")) + expect_identical(unique(p$params$param), "a") +}) + +test_that("`which` keeps only those statuses, in any case, in fits, gens and params", { + root <- fourFits() + all <- DEoptimProgress(root) + expect_identical(DEoptimProgress(root, which = c("running", "stopped", "finished", "errored"))$fits$status, + all$fits$status) + for (w in list("running", "STOPPED", "Finished", "errored", c("running", "ERRORED"))) { + p <- DEoptimProgress(root, which = w) + expect_true(all(p$fits$status %in% toupper(w))) + expect_setequal(p$fits$status, toupper(w)) + expect_setequal(p$gens$fit, p$fits$fit) + expect_setequal(p$params$fit, p$fits$fit) + } + expect_identical(nrow(DEoptimProgress(root, which = "running", runningWithin = 1)$fits), 0L) + expect_identical(nrow(DEoptimProgress(root, which = "stopped", runningWithin = 7200)$fits), 0L) + expect_error(DEoptimProgress(root, which = "crashed")) +}) + +test_that("a fit whose objective errors ends its progress file with ERRORED; a finished one with FINISHED", { + skip_if_not_installed("DEoptim") + withr::local_dir(withr::local_tempdir()) + cp <- withr::local_tempdir() + withr::local_options(reproducible.cachePath = cp, reproducible.useCache = FALSE, + clusters.cacheDEoptimIterations = TRUE) + calls <- 0L + fn <- function(par) { calls <<- calls + 1L; if (calls > 20L) stop("objective failed"); sum((par - 0.3)^2) } + expect_error(suppressMessages(clusters:::DEoptimIterative( + fn, lower = c(a = 0, b = 0), upper = c(a = 1, b = 1), + control = list(NP = 8L, strategy = 2L, itermax = 5, trace = FALSE), iterStep = 1, + figurePath = FALSE, .plots = NULL, cachePath = cp, runName = "err", .verbose = -1)), "objective failed") + p <- data.table::fread("DEoptimProgress_err.csv") + expect_identical(p$variable[nrow(p)], "ERRORED") + expect_identical(sum(p$variable == "FINISHED"), 0L) + expect_identical(DEoptimProgress(".")$fits$status, "ERRORED") + expect_identical(nrow(DEoptimProgress(".", which = "running")$fits), 0L) + expect_identical(nrow(DEoptimProgress(".", which = "stopped")$gens), 0L) +}) + +test_that("a fit reached through a symlinked folder inside `path` is listed once", { + skip_on_os("windows") + root <- withr::local_tempdir() + writeProgress(file.path(root, "ELF1", "DEoptimProgress_1.csv")) + writeProgress(file.path(root, "ELF2", "DEoptimProgress_1.csv")) + dir.create(file.path(root, "links")) + file.symlink(file.path(root, "ELF1"), file.path(root, "links", "ELF1")) + state <- new.env() + for (i in 1:2) { # and again on a refresh with the same state + p <- DEoptimProgress(root, state = state) + expect_identical(sort(p$fits$label), c("ELF1", "ELF2")) + expect_identical(nrow(p$gens), 4L) + expect_identical(nrow(clusters:::.fitsData(p$fits)), 2L) + } +})