From 07ee91847f97a528501bc024ee8aa02ba570c9df Mon Sep 17 00:00:00 2001 From: Chris Guiterman Date: Tue, 29 Nov 2022 14:20:38 -0500 Subject: [PATCH] Data QC check function and implementation. Addresses #77, and includes multiple new unit tests --- DESCRIPTION | 4 +- NAMESPACE | 14 +++ NEWS.md | 6 +- R/io.R | 206 +++++++++++++++++++++++++++++++++++--- man/check_series.Rd | 38 +++++++ tests/testthat/Rplots.pdf | Bin 8318 -> 8330 bytes tests/testthat/test-io.R | 40 +++++++- 7 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 man/check_series.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 99d9555..55128a9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -18,7 +18,9 @@ Suggests: knitr, rmarkdown Imports: + cli, forcats, + dplyr, ggplot2, glue, MASS, @@ -28,6 +30,6 @@ Imports: stats, stringr, tidyr -RoxygenNote: 7.1.2 +RoxygenNote: 7.2.1 Roxygen: list(markdown = TRUE) VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index 7fcc018..040f32f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -15,6 +15,7 @@ S3method(sort,fhx) S3method(summary,fhx) export(as.fhx) export(as_fhx) +export(check_series) export(composite) export(count_event_position) export(count_injury) @@ -51,9 +52,22 @@ export(series_stats) export(write_fhx) export(year_range) export(yearly_recording) +importFrom(cli,cli_alert_danger) +importFrom(cli,cli_alert_info) +importFrom(dplyr,"%>%") +importFrom(dplyr,case_when) +importFrom(dplyr,filter) +importFrom(dplyr,group_by) +importFrom(dplyr,mutate) +importFrom(dplyr,n) +importFrom(dplyr,summarize) +importFrom(forcats,fct_collapse) +importFrom(forcats,fct_count) importFrom(glue,glue) importFrom(rlang,.data) importFrom(rlang,abort) importFrom(stats,median) importFrom(stats,quantile) +importFrom(stringr,str_c) importFrom(tidyr,pivot_wider) +importFrom(utils,write.table) diff --git a/NEWS.md b/NEWS.md index 8852a94..b2402f0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,9 +2,9 @@ Changes in this release: -* Fixes `print()` for `intervals` objects so they print the correct quantiles, in the correct order. Prevously, the order was reversed and the 0.875 quantile was actually the 0.847 quantile. (@chguiterman, #202) +* Fixes `print()` for `intervals` objects so they print the correct quantiles, in the correct order. Previously, the order was reversed and the 0.875 quantile was actually the 0.847 quantile. (@chguiterman, #202) -* Update `plot_demograph()` code to allow for dropped aethesthetics and resolve erroneous legends (@chguiterman, #199) +* Update `plot_demograph()` code to allow for dropped aesthetics and resolve erroneous legends (@chguiterman, #199) * Add `glue` as package dependency. This helps to elaborate error messages (@chguiterman, #196) @@ -12,6 +12,8 @@ Changes in this release: * Update `sea()` so that only event years that are used are provided in the list output (@chguiterman, #187) +* Add new function, `check_series()`, to provide feedback on potential data quality issues. This is called by `read_fhx()` and `write_fhx()` to warn users, but in the latter will trigger an error because data issues violate the creation of an FHX file or in some cases should be corrected before the data are passed on. These additions address issue #77 + # burnr v0.6.1 diff --git a/R/io.R b/R/io.R index 9cdb75a..aa77b11 100644 --- a/R/io.R +++ b/R/io.R @@ -130,9 +130,178 @@ read_fhx <- function(fname, encoding, text) { year = fl_body_melt$year, series = fl_body_melt$series, rec_type = fl_body_melt$rec_type ) + check_series(f) f } +#' Diagnostic checks of common data issues in FHX objects +#' +#' @param x An `fhx` object. +#' @param keep_checks Boolean indicating whether the check data should be +#' provided for further diagnostics. Defaults to FALSE, and only warnings or +#' information are provided. +#' +#' @return If necessary, a set of warnings and informational text to indicate +#' where and when issues are found. If `keep_checks = TRUE`, a list object +#' with the set of check data. +#' +#' @importFrom dplyr %>% mutate filter group_by summarize n case_when +#' @importFrom rlang .data +#' @importFrom forcats fct_collapse fct_count +#' @importFrom cli cli_alert_info cli_alert_danger +#' @importFrom stringr str_c +#' +#' @examples +#' # Nothing is returned unless an issue is found +#' check_series(lgr2) +#' +#' # Informative messages flag potential issues +#' check_series(pgm) +#' +#' # Danger flags warn of data quality and usability issues +#' \dontrun{ +#' d <- read_fhx(paste0("https://github.com/ltrr-arizona-edu/burnr/", +#' "files/818119/Dumb_broke_fhx.txt")) +#' check_series(d) +#' } +#' +#' @export + +check_series <- function(x, keep_checks = FALSE) { + stopifnot(is_fhx(x)) + + ## Read in file and collapse rec_types + check_file <- x %>% + mutate(gen_type = suppressWarnings( + fct_collapse(.data$rec_type, + recorder = c(rec_type_recorder, + rec_type_injury), + inner = c("pith_year", "inner_year"), + outer = c("bark_year", "outer_year")) + ) + ) + + ## Count end types + end_code_counts <- check_file %>% + filter(.data$gen_type %in% c("inner", "outer")) %>% + group_by(.data$series) %>% + summarize(fct_count(factor(.data$gen_type))) %>% + suppressMessages() + + ## Excluded start/end indicators + no_ends <- end_code_counts %>% + group_by(.data$series) %>% + summarize(n = n()) %>% + filter(.data$n < 2) + + if (nrow(no_ends) > 0) { + for (i in 1:nrow(no_ends)) { + bad_series <- no_ends$series[i] %>% as.character() + bad_type <- ifelse(filter(end_code_counts, + .data$series == bad_series)$f %>% + as.character() == "inner", "outer", "inner") + cli_alert_info("{bad_series} is missing a specific {bad_type} year code.") + } + } + + ## Duplicate start/end indicators + bad_ends <- end_code_counts %>% + filter(.data$n > 1) + + if (nrow(bad_ends) > 0){ + for (i in 1:nrow(bad_ends)) { + bad_series <- bad_ends$series[i] %>% as.character() + bad_type <- bad_ends$f[i] %>% as.character() + years <- str_c(filter(check_file, + .data$series == bad_series, + .data$gen_type == bad_type)$year, + collapse = " and ") + cli_alert_danger("{bad_series} includes duplicate {bad_type} year codes in {years}.") + } + } + + ## ID series lacking scar/injury codes + empty_series <- check_file %>% + group_by(.data$series) %>% + summarize(n_rec = sum(.data$gen_type == "recorder")) %>% + filter(.data$n_rec == 0) + + if (nrow(empty_series) > 0) { + for (i in 1:nrow(empty_series)) { + cli_alert_info("{empty_series$series[i]} does not include any scar or injury features.") + } + } + + ## Check whether scars/injuries exist beyond start/end years + rec_diffs <- check_file %>% + filter(! .data$series %in% no_ends$series, + ! .data$series %in% empty_series$series) %>% + group_by(.data$series) %>% + summarize(inner_diff = .data$year[.data$gen_type == "inner"] - + min(.data$year[.data$gen_type == "recorder"]), + outer_diff = max(.data$year[.data$gen_type == "recorder"] - + max(.data$year[.data$gen_type == "outer"])) + ) + ## Recorder years the same as inner or outer year + dup_years <- rec_diffs %>% + filter(.data$inner_diff == 0 | .data$outer_diff == 0) + + if (nrow(dup_years) > 0) { + for (i in 1:nrow(dup_years)) { + bad_series <- dup_years$series[i] %>% as.character() + position <- dplyr::case_when( + dup_years$inner_diff[i] == 0 & dup_years$outer_diff[i] == 0 + ~ "both inner and outer year codes", + dup_years$inner_diff[i] == 0 ~ "the inner-year code", + dup_years$outer_diff[i] == 0 ~ "the outer-year code" + ) + cli_alert_danger( + c("{bad_series} includes a scar or injury code", + " in the same year as {position}."), + wrap = TRUE + ) + } + } + + ## Recorder years before inner year + inner_diffs <- rec_diffs %>% + filter(.data$inner_diff > 0) + + if (nrow(inner_diffs) > 0) { + for (i in 1:nrow(inner_diffs)) { + bad_series <- inner_diffs$series[i] %>% as.character() + cli_alert_danger( + c("{bad_series} includes a scar or injury code", + " {inner_diffs$inner_diff[i]} years before the inner-year code."), + wrap = TRUE + ) + } + } + + ## Recorder years after outer year + outer_diffs <- rec_diffs %>% + filter(.data$outer_diff > 0) + + if (nrow(outer_diffs) > 0){ + for (i in 1:nrow(outer_diffs)) { + bad_series <- outer_diffs$series[i] %>% as.character() + cli_alert_danger( + c("{bad_series} includes a scar or injury code", + " {outer_diffs$outer_diff[i]} years after the outer-year code."), + wrap = TRUE + ) + } + } + if (keep_checks) { + return(list( + "no_ends" = no_ends, + "bad_ends" = bad_ends, + "dup_ends" = dup_years, + "emtpy_series" = empty_series, + "inner_diffs" = inner_diffs, + "outer_diffs" = outer_diffs)) + } +} #' List of character strings to write to FHX file #' @@ -199,6 +368,10 @@ list_filestrings <- function(x) { #' * [write.csv()] to write a CSV file. Also works on `fhx` objects. #' * [read_fhx()] to read an FHX2 file. #' +#' @importFrom rlang abort +#' @importFrom utils write.table +#' @importFrom cli cli_alert_info +#' #' @examples #' \dontrun{ #' data(lgr2) @@ -211,10 +384,18 @@ write_fhx <- function(x, fname = "") { stop("Please specify a character string naming a file or connection open for writing.") } + + chk_list <- check_series(x, keep_checks = TRUE) + if (any( + c(nrow(chk_list$dup_ends) > 0, + nrow(chk_list$inner_diffs) > 0, + nrow(chk_list$outer_diffs) > 0) + ) + ) abort("Data errors listed above prevent the creation of an FHX file") if (violates_canon(x)) { - warning( - "`write_fhx()` run on `fhx` object with rec_types that violate FHX2", - " canon - other software may not be able to read the output FHX file" + cli_alert_info( + c("The output file includes codes that violate FHX2", + " canon. Other software may not be able to read the file") ) } d <- list_filestrings(x) @@ -222,22 +403,23 @@ write_fhx <- function(x, fname = "") { cat(paste(d[["head_line"]], "\n", d[["subhead_line"]], "\n", sep = ""), file = fl, sep = "" ) - utils::write.table(d[["series_heading"]], fl, - append = TRUE, quote = FALSE, - sep = "", na = "!", - row.names = FALSE, col.names = FALSE + write.table(d[["series_heading"]], fl, + append = TRUE, quote = FALSE, + sep = "", na = "!", + row.names = FALSE, col.names = FALSE ) cat("\n", file = fl, sep = "", append = TRUE) - utils::write.table(d[["body"]], fl, - append = TRUE, quote = FALSE, - sep = "", na = "!", - row.names = FALSE, col.names = FALSE + write.table(d[["body"]], fl, + append = TRUE, quote = FALSE, + sep = "", na = "!", + row.names = FALSE, col.names = FALSE ) + close(fl) } -#' Convert abreviated `fhx` file event char to rec_type char +#' Convert abbreviated `fhx` file event char to rec_type char #' #' @param x A character string. #' diff --git a/man/check_series.Rd b/man/check_series.Rd new file mode 100644 index 0000000..5f20a28 --- /dev/null +++ b/man/check_series.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/io.R +\name{check_series} +\alias{check_series} +\title{Diagnostic checks of common data issues in FHX objects} +\usage{ +check_series(x, keep_checks = FALSE) +} +\arguments{ +\item{x}{An \code{fhx} object.} + +\item{keep_checks}{Boolean indicating whether the check data should be +provided for further diagnostics. Defaults to FALSE, and only warnings or +information are provided.} +} +\value{ +If necessary, a set of warnings and informational text to indicate +where and when issues are found. If \code{keep_checks = TRUE}, a list object +with the set of check data. +} +\description{ +Diagnostic checks of common data issues in FHX objects +} +\examples{ +# Nothing is returned unless an issue is found +check_series(lgr2) + +# Informative messages flag potential issues +check_series(pgm) + +# Danger flags warn of data quality and usability issues +\dontrun{ +d <- read_fhx(paste0("https://github.com/ltrr-arizona-edu/burnr/", + "files/818119/Dumb_broke_fhx.txt")) +check_series(d) +} + +} diff --git a/tests/testthat/Rplots.pdf b/tests/testthat/Rplots.pdf index 3e1528c7587ea3f1c1e2b578631610101e563983..0143f9c1e7abb4c47e8f6caddf39e9283c6cd093 100644 GIT binary patch delta 4091 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 T66Hm7IaHtkadBN^J;46}g^>gk delta 4079 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