From 2e53273e3e99cbbf0351aec52771e25e7265e008 Mon Sep 17 00:00:00 2001 From: Will Curran-Groome Date: Mon, 24 Aug 2026 17:37:23 -0400 Subject: [PATCH] gage updates, removing damage assessments to separate repo, minor tweaks to cache_it column types --- NAMESPACE | 2 - NEWS.md | 15 + R/cache_it.R | 47 +- R/get_preliminary_damage_assessments.R | 2463 ----------------- R/get_usgs_gage.R | 38 +- _pkgdown.yml | 2 - man/cache_it.Rd | 7 + man/climateapi-package.Rd | 1 + man/get_preliminary_damage_assessments.Rd | 215 -- man/get_usgs_gage.Rd | 11 +- man/scrape_pda_pdfs.Rd | 73 - tests/testthat/test-cache_it.R | 63 + .../test-get_preliminary_damage_assessments.R | 365 --- vignettes/get_usgs_gage.Rmd.orig | 7 + vignettes/preliminary_damage_assessments.Rmd | 345 --- .../preliminary_damage_assessments.Rmd.orig | 234 -- vignettes/preliminary_damage_assessments.qmd | 330 --- ...reliminary_damage_assessments_original.Rmd | 151 - 18 files changed, 168 insertions(+), 4201 deletions(-) delete mode 100644 R/get_preliminary_damage_assessments.R delete mode 100644 man/get_preliminary_damage_assessments.Rd delete mode 100644 man/scrape_pda_pdfs.Rd delete mode 100644 tests/testthat/test-get_preliminary_damage_assessments.R delete mode 100644 vignettes/preliminary_damage_assessments.Rmd delete mode 100644 vignettes/preliminary_damage_assessments.Rmd.orig delete mode 100644 vignettes/preliminary_damage_assessments.qmd delete mode 100644 vignettes/preliminary_damage_assessments_original.Rmd diff --git a/NAMESPACE b/NAMESPACE index 130b59f..6e7fa81 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -27,7 +27,6 @@ export(get_nfip_residential_penetration) export(get_nlcd_land_cover) export(get_nlcd_land_cover_colors) export(get_openfema_cache_path) -export(get_preliminary_damage_assessments) export(get_public_assistance) export(get_sba_loans) export(get_sheldus) @@ -41,6 +40,5 @@ export(list_openfema_endpoints) export(read_ipums_cached) export(read_xlsx_from_url) export(register_hud_api_key) -export(scrape_pda_pdfs) importFrom(magrittr,"%>%") importFrom(rlang,.data) diff --git a/NEWS.md b/NEWS.md index 1dfe56f..9d4b6f8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,19 @@ # climateapi (development version) +* `get_preliminary_damage_assessments()` now returns `pda_ia_requested` and + `pda_pa_requested` as TRUE/FALSE rather than 1/0, matching the `fema_*_requested` + columns. +* `get_preliminary_damage_assessments()` now reads the Public Assistance cost + estimate from reports that print the label as "Total Public Assistance Cost + Estimate" rather than "cost estimate" (disasters 1736-1739), which previously + came back as missing. +* `get_preliminary_damage_assessments()` now fills in a missing or out-of-range + countywide per capita impact indicator the same way it already filled the + statewide one: with the value the other reports of the same federal fiscal + year state. +* Adding `transform_pda_counties()`, which splits the county listing a + preliminary damage assessment report prints for Public Assistance into one row + per report-county, with the county's FIPS code and its per capita impact as a + number. ## v0.0.0.9001 * Updating README diff --git a/R/cache_it.R b/R/cache_it.R index 267f284..0250436 100644 --- a/R/cache_it.R +++ b/R/cache_it.R @@ -5,6 +5,12 @@ #' file if one exists. For sf objects, the function automatically uses sfarrow for #' reading/writing and adds "_sf" to the filename to indicate the file format. #' +#' Parquet stores durations in seconds, so a difftime column written in another unit +#' (days, hours, minutes, weeks) would otherwise be read back labelled in seconds. The +#' original units are recorded when the file is written and re-applied when it is read, +#' so difftime columns keep the units they were cached with. Files cached before this +#' behavior was added carry no record of their units and are read back in seconds. +#' #' @param object A dataframe, tibble, or sf object to cache. Can be provided as #' either a quoted or unquoted name. Optional when reading from cache - in this #' case, file_name must be provided. @@ -171,6 +177,24 @@ cache_it <- function(object, list.files(path, pattern = pattern, full.names = TRUE) } + # Helper function to restore the original units of difftime columns. Arrow stores + # durations in seconds, so a column written in days is read back as an equivalent + # number of seconds; the original units are recorded as a column attribute at write + # time and re-applied here. + restore_difftime_units <- function(data) { + recorded_units <- purrr::map(data, \(column) attr(column, "climateapi_difftime_units")) + columns_to_restore <- names(recorded_units)[!purrr::map_lgl(recorded_units, is.null)] + + for (column_name in columns_to_restore) { + if (inherits(data[[column_name]], "difftime")) { + units(data[[column_name]]) <- recorded_units[[column_name]] + } + attr(data[[column_name]], "climateapi_difftime_units") <- NULL + } + + data + } + # Handle reading based on read parameter if (isTRUE(read)) { cached_files <- find_cached_files() @@ -192,9 +216,9 @@ cache_it <- function(object, message("Reading cached file: ", basename(most_recent_file), " (dated ", most_recent_date, ")") if (file_is_sf) { - return(sfarrow::st_read_parquet(most_recent_file)) + return(restore_difftime_units(sfarrow::st_read_parquet(most_recent_file))) } else { - return(arrow::read_parquet(most_recent_file)) + return(restore_difftime_units(arrow::read_parquet(most_recent_file))) } } else { message("No cached files found for '", file_name, "'. Writing new file.") @@ -211,9 +235,9 @@ cache_it <- function(object, message("Reading cached file: ", read) if (file_is_sf) { - return(sfarrow::st_read_parquet(specific_path)) + return(restore_difftime_units(sfarrow::st_read_parquet(specific_path))) } else { - return(arrow::read_parquet(specific_path)) + return(restore_difftime_units(arrow::read_parquet(specific_path))) } } else { stop("Specified file does not exist: ", specific_path) @@ -228,10 +252,21 @@ cache_it <- function(object, stop("No cached file found and no object provided to write. Please provide an object or check the file_name/path.") } + # Record the units of any difftime columns so they can be restored on read; arrow + # otherwise converts these columns to seconds + object_to_write <- object + difftime_columns <- names(object_to_write)[ + purrr::map_lgl(object_to_write, \(column) inherits(column, "difftime"))] + + for (column_name in difftime_columns) { + attr(object_to_write[[column_name]], "climateapi_difftime_units") <- + units(object_to_write[[column_name]]) + } + if (is_sf) { - sfarrow::st_write_parquet(obj = object, dsn = full_path) + sfarrow::st_write_parquet(obj = object_to_write, dsn = full_path) } else { - arrow::write_parquet(object, full_path, compression = "snappy") + arrow::write_parquet(object_to_write, full_path, compression = "snappy") } message("Cached to: ", basename(full_path)) diff --git a/R/get_preliminary_damage_assessments.R b/R/get_preliminary_damage_assessments.R deleted file mode 100644 index 79e1b94..0000000 --- a/R/get_preliminary_damage_assessments.R +++ /dev/null @@ -1,2463 +0,0 @@ -#' Derive collision-free local filenames for a set of PDA report URLs -#' -#' File names can have tricky characters, including escaped/special characters -#' derived from non-ASCI text. This handles that complexity. -#' -#' @param urls A character vector of PDF URLs. -#' -#' @return A tibble with one row per distinct URL, containing `url` and -#' `destination_file` (a basename, not a full path). -#' @noRd -resolve_pdf_destinations = function(urls) { - - destinations1 = tibble::tibble(url = unique(urls)) %>% - dplyr::mutate( - base_name = url %>% - ## a query string or fragment left in place produces an illegal Windows - ## filename ("report.pdf?v=2" -> "report?v=2.pdf") that cannot be written - stringr::str_remove("[?#].*$") %>% - stringr::str_extract("[^/]+$") %>% - curl::curl_unescape() %>% - stringr::str_remove(stringr::regex("\\.pdf$", ignore_case = TRUE)) %>% - stringr::str_replace_all("[<>:\"/\\\\|?*]", "_") %>% - stringr::str_remove_all("[[:cntrl:]]") %>% - stringr::str_squish() %>% - stringr::str_replace_all("\\s+", "_")) %>% - dplyr::add_count(base_name, name = "base_name_count") %>% - dplyr::mutate( - needs_hash = base_name_count > 1 | is.na(base_name) | base_name == "", - destination_file = dplyr::if_else( - needs_hash, - stringr::str_c( - dplyr::coalesce(base_name, "pda-report"), "-", - purrr::map_chr(url, ~ stringr::str_sub(rlang::hash(.x), 1, 8)), - ".pdf"), - stringr::str_c(base_name, ".pdf"))) - - if (any(destinations1$needs_hash)) { - is_empty_name = is.na(destinations1$base_name) | destinations1$base_name == "" - collided = destinations1$needs_hash & !is_empty_name - - message( - stringr::str_c( - sum(destinations1$needs_hash), - " report URL(s) were given a URL hash so that every report is retained: ", - sum(collided), - " reduced to a filename already claimed by a different URL, and ", - sum(is_empty_name), - " reduced to no filename at all. Affected: ", - stringr::str_c( - destinations1$destination_file[destinations1$needs_hash], - collapse = ", "))) } - - destinations1 %>% dplyr::select(url, destination_file) -} - -#' The browser identity FEMA's site requires -#' -#' @return A length-one character `User-Agent` string. -#' @noRd -browser_user_agent = function() { - stringr::str_c( - "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 ", - "(KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile ", - "Safari/537.36") -} - -#' Download a PDF from a URL and verify -#' -#' A download is kept only if the resulting file begins with the `%PDF` magic -#' number and exceeds `minimum_bytes`. Anything else -- an HTML error page served -#' with a 200 status, a truncated transfer -- is deleted. -#' -#' @param url URL to the pdf. -#' @param destfile The full local path the PDF should be written to, as -#' resolved by `resolve_pdf_destinations()`. -#' @param minimum_bytes Files smaller than this are treated as failed -#' downloads. The smallest genuine report currently cached is roughly 21 KB. -#' -#' @return One of `"cached"`, `"downloaded"`, or `"failed"`. -#' @noRd -save_pdf = function(url, destfile, minimum_bytes = 2048) { - - if (file.exists(destfile)) { return("cached") } - - headers = c( - "User-Agent" = browser_user_agent(), - "Accept" = "application/pdf", - "Accept-Language" = "en-US", - "Connection" = "keep-alive") - - ## Some listing links are percent-encoded twice: FEMA publishes - ## "...Luise%25C3%25B1oIndians.pdf", where "%25" is an encoded "%", so the - ## request asks for a file literally named "Luise%C3%B1o..." and returns 404. - ## Undoing one layer of encoding recovers it. - candidate_urls = unique(c(url, stringr::str_replace_all(url, "%25", "%"))) - downloaded = FALSE - for (candidate_url in candidate_urls) { - downloaded = tryCatch({ - utils::download.file( - url = candidate_url, - destfile = destfile, - headers = headers, - mode = "wb") - TRUE}, - error = function(e) FALSE, - warning = function(w) NA) - - if (isTRUE(downloaded)) { break } } - - is_pdf = FALSE - if (file.exists(destfile) && file.size(destfile) >= minimum_bytes) { - connection = file(destfile, "rb") - file_header = tryCatch( - rawToChar(readBin(connection, "raw", 4)), - finally = close(connection)) - is_pdf = identical(file_header, "%PDF") } - - if (!is_pdf) { - ## remove the partial or wrong-content file so this URL is retried next run - if (file.exists(destfile)) { file.remove(destfile) } - warning( - stringr::str_c("Could not download a valid PDF from: ", url), - call. = FALSE) - return("failed") } - - "downloaded" -} - -#' @title Download Preliminary Damage Assessment (PDA) Reports to Disk -#' -#' @description Downloads every PDA report that FEMA publishes into a local -#' directory, so that `get_preliminary_damage_assessments()` has a complete and -#' current set of source documents to parse. Run this before regenerating the -#' dataset; `get_preliminary_damage_assessments()` parses whatever is already on -#' disk and never fetches anything itself. -#' -#' @details Walks every page of FEMA's PDA report listing at -#' https://www.fema.gov/disaster/how-declared/preliminary-damage-assessments/reports -#' and downloads any report not -#' already present in `cache_directory`. -#' -#' A page that errors is retried, and exhausting the retries raises an error -#' rather than ending the walk. -#' -#' @param cache_directory The folder where scraped PDFs are written. -#' @param max_pages A guard against an unbounded walk if the listing ever stops -#' returning empty pages. -#' @param attempts_per_page How many times to try a listing page before treating -#' it as a failure. -#' @param pages Which listing pages to read, as a numeric vector. The default -#' `NULL` walks the whole listing until a page returns no links, which is the -#' only setting that guarantees complete coverage. FEMA lists newest first, so -#' `pages = 0:2` is enough to pick up recently published reports and is much -#' faster. Two caveats when this is set: cached files that are no longer listed -#' cannot be detected, because the full listing was never read; and filename -#' collisions are resolved only against the pages requested, so a name could be -#' assigned that a page outside the range also claims. -#' @param delay_seconds Seconds to pause between listing pages, to avoid -#' hammering FEMA's site. This is what dominates the running time of a full -#' walk -- roughly 70 pages -- not the downloads, which are skipped for reports -#' already in `cache_directory`. -#' @param quiet Suppress progress messages? The walk covers roughly 70 pages with -#' a pause between each and is otherwise silent for several minutes, so progress -#' is reported by default. Warnings about failed downloads and about cached -#' files no longer listed are always raised, regardless of this setting. -#' -#' @return Invisibly, a tibble with one row per report found on the site, -#' containing `url`, `destination_file`, and `status` (`"cached"`, -#' `"downloaded"`, or `"failed"`). Called for its side effect: PDFs are written -#' to `cache_directory`. -#' @export -#' -#' @examples -#' \dontrun{ -#' ## refresh the local archive, then rebuild the dataset from it -#' scrape_pda_pdfs() -#' get_preliminary_damage_assessments(use_cache = FALSE) -#' } -scrape_pda_pdfs = function( - cache_directory = file.path(climateapi::get_box_path(), "hazards", "urban", "preliminary-damage-assessments", "pdfs"), - pages = NULL, - max_pages = 200, - attempts_per_page = 5, - delay_seconds = 2, - quiet = FALSE) { - - ## progress reporting only; warnings are raised unconditionally elsewhere - notify = function(...) { if (!quiet) { message(stringr::str_c(...)) } } - - base_url = "https://www.fema.gov/disaster/how-declared/preliminary-damage-assessments/reports?page=" - - ## restored on exit so the function does not leave the user's session altered - original_timeout = getOption("timeout") - on.exit(options(timeout = original_timeout), add = TRUE) - options(timeout = 200) - - listing_headers = httr::add_headers( - "User-Agent" = browser_user_agent(), - "Accept" = "text/html", - "Accept-Language" = "en-US") - - walk_whole_listing = is.null(pages) - page_queue = if (walk_whole_listing) NULL else as.numeric(pages) - - if (!walk_whole_listing) { - notify( - "Reading only page(s) ", stringr::str_c(sort(unique(page_queue)), collapse = ", "), - ". Withdrawn-report detection is skipped and filename collisions are ", - "resolved only across these pages.") } - - urls1 = character(0) - page_number = if (walk_whole_listing) 0 else page_queue[1] - page_index = 1 - pages_read = 0 - - repeat { - page_urls = NULL - notify("Reading listing page ", page_number, ".") - - for (attempt in seq_len(attempts_per_page)) { - page_urls = tryCatch({ - response = httr::GET(stringr::str_c(base_url, page_number), listing_headers) - - if (httr::status_code(response) != 200) { stop("non-200 response") } - - httr::content(response, as = "text", encoding = "UTF-8") %>% - rvest::read_html() %>% - rvest::html_elements("a") %>% - rvest::html_attr("href") %>% - purrr::discard(~ is.na(.x)) %>% - purrr::keep(~ stringr::str_detect(.x, stringr::regex("\\.pdf$", ignore_case = TRUE)))}, - error = function(e) NULL) - - if (!is.null(page_urls)) { break } - Sys.sleep(min(120, delay_seconds * 2 ^ attempt)) - } - - if (is.null(page_urls)) { - stop( - stringr::str_c( - "Could not read listing page ", page_number, " after ", - attempts_per_page, " attempts, most likely because FEMA is rate-", - "limiting the walk. Stopping rather than continuing, because ", - "treating a failed page as the end of the listing would silently ", - "omit every report beyond it.\n", - "Reports from pages already read were not downloaded. To resume from ", - "where this stopped, wait a few minutes and call:\n", - " scrape_pda_pdfs(pages = ", page_number, ":", - page_number + 49, ")\n", - "or raise delay_seconds / attempts_per_page for a slower, more ", - "patient walk."), - call. = FALSE) } - - if (length(page_urls) == 0 && walk_whole_listing) { break } - - urls1 = c(urls1, page_urls) - pages_read = pages_read + 1 - - if (walk_whole_listing) { - page_number = page_number + 1 - if (page_number >= max_pages) { - stop( - stringr::str_c( - "Reached max_pages (", max_pages, ") without finding an empty ", - "listing page. Coverage may be incomplete; raise max_pages or check ", - "whether the listing URL still paginates as expected."), - call. = FALSE) } - } else { - page_index = page_index + 1 - if (page_index > length(page_queue)) { break } - page_number = page_queue[page_index] } - - Sys.sleep(delay_seconds) - } - - ## listing hrefs are site-relative, but tolerate an absolute URL in case the - ## markup changes - urls2 = dplyr::if_else( - stringr::str_detect(urls1, "^https?://"), - urls1, - stringr::str_c("https://www.fema.gov", urls1)) %>% - unique() - - destinations = resolve_pdf_destinations(urls2) - - notify( - "Found ", nrow(destinations), " report(s) across ", pages_read, - " listing page(s).") - - destinations2 = destinations %>% - dplyr::mutate( - status = purrr::map2_chr( - url, - file.path(cache_directory, destination_file), - ~ save_pdf(url = .x, destfile = .y))) - - notify( - "Downloaded ", sum(destinations2$status == "downloaded"), "; already ", - "cached ", sum(destinations2$status == "cached"), "; failed ", - sum(destinations2$status == "failed"), ".") - - if (any(destinations2$status == "failed")) { - warning( - stringr::str_c( - sum(destinations2$status == "failed"), - " report(s) could not be downloaded and are absent from the cache. ", - "They were not written to disk, so re-running will retry them. ", - "Do not treat the archive as complete until this count is zero."), - call. = FALSE) } - - ## Files present locally but not listed on the site. - orphans = setdiff( - list.files(cache_directory, recursive = TRUE, pattern = "(?i)pdf$"), - destinations2$destination_file) - - if (length(orphans) > 0) { - warning( - stringr::str_c( - length(orphans), " cached file(s) are no longer listed on FEMA's site ", - "and may have been withdrawn or renamed. They are still on disk and ", - "will still be parsed into the dataset. Review before publishing: ", - stringr::str_c(orphans, collapse = ", ")), - call. = FALSE) } - - invisible(destinations2) -} - -#' Extract the value a report prints between two field labels -#' -#' @param text The report text to search. -#' @param term1 The label the value follows. -#' @param term2 The label the value runs up to. -#' -#' @return The text between `term1` and `term2`, excluding both terms. -#' @noRd -extract_value = function(text, term1, term2) { - - ## Colons are optional because most reports render these fields as - ## "