diff --git a/.github/workflows/update-stats.yaml b/.github/workflows/update-stats.yaml new file mode 100644 index 0000000..6206cf1 --- /dev/null +++ b/.github/workflows/update-stats.yaml @@ -0,0 +1,45 @@ +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' # Every Monday at 6am UTC + +name: Update stats + +jobs: + update-stats: + runs-on: ubuntu-latest + permissions: + contents: write + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + CROSSREF_EMAIL: ${{ secrets.CROSSREF_EMAIL }} + steps: + - uses: actions/checkout@v7 + + - uses: r-lib/actions/setup-r@v2 + with: + install-r: true + use-public-rspm: true + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install libcurl4-openssl-dev + + - uses: r-lib/actions/setup-renv@v2 + + - name: Collect package and paper metrics + run: source("snippets/update_stats.R") + shell: Rscript {0} + + - name: Commit the new rows + run: | + git config user.name "epiforecasts-bot" + git config user.email "epiforecasts-bot@users.noreply.github.com" + git add _data/package-stats.csv _data/paper-citations.csv + if git diff --staged --quiet; then + echo "No change this week" + else + git commit -m "Weekly package and paper metrics" + git push + fi diff --git a/_automation/get_stats.R b/_automation/get_stats.R new file mode 100644 index 0000000..848a9e4 --- /dev/null +++ b/_automation/get_stats.R @@ -0,0 +1,189 @@ +## Collect weekly metrics for the group's packages and papers. +## +## Two sources of truth, both already maintained elsewhere: the r-universe +## registry plus _data/software-extras.yml decide which packages count, and +## _data/papers.bib decides which papers count. Nothing here needs its own +## hand-kept list. + +openalex_mailto <- function() { + email <- Sys.getenv("CROSSREF_EMAIL") + if (identical(email, "")) NULL else email +} + +## Citation counts are tracked for papers only. Packages rarely declare a +## citable reference: of sixteen, three do, and one of those is a CRAN package +## DOI that is almost never cited, so a package citation column would be blank +## or misleading nearly everywhere. +## +## OpenAlex is generous but rate-limits anonymous callers, so identify us where +## an address is available. +openalex_citations <- function(doi) { + url <- paste0("https://api.openalex.org/works/doi:", utils::URLencode(doi)) + mailto <- openalex_mailto() + if (!is.null(mailto)) url <- paste0(url, "?mailto=", mailto) + res <- tryCatch(jsonlite::fromJSON(url), error = function(err) NULL) + if (is.null(res) || is.null(res$cited_by_count)) { + return(list( + citations = NA_integer_, + title = NA_character_, + year = NA_integer_ + )) + } + list( + citations = as.integer(res$cited_by_count), + title = res$display_name %||% NA_character_, + year = as.integer(res$publication_year %||% NA) + ) +} + +## ---- packages -------------------------------------------------------------- + +## Every repo the software page shows, from the same two sources it uses. +package_repos <- function() { + universe <- jsonlite::read_json( + "https://github.com/epiforecasts/universe/raw/main/packages.json" + ) + flagged <- purrr::keep(universe, ~ isTRUE(.x$display_website)) + from_universe <- purrr::map_chr(flagged, function(e) { + sub("^https://github.com/", "", e$url) + }) + + extras <- yaml::read_yaml("_data/software-extras.yml") + from_extras <- purrr::map_chr(extras, "repo") + + unique(c(from_universe, from_extras)) +} + +github_stats <- function(repo) { + info <- tryCatch( + gh::gh("/repos/{repo}", repo = repo), + error = function(err) NULL + ) + if (is.null(info)) { + return(list(stars = NA_integer_, forks = NA_integer_, issues = NA_integer_)) + } + list( + stars = as.integer(info$stargazers_count %||% NA), + forks = as.integer(info$forks_count %||% NA), + issues = as.integer(info$open_issues_count %||% NA) + ) +} + +## r-universe reports a download count per package, sourced from cranlogs. For +## a package that is not on CRAN that count is always zero, which would read as +## "nobody downloaded it" rather than "this figure does not apply", so keep the +## CRAN flag alongside it and blank the count when it does not apply. +universe_downloads <- function() { + pkgs <- jsonlite::fromJSON( + "https://epiforecasts.r-universe.dev/api/packages", + simplifyVector = FALSE + ) + stats <- purrr::map(pkgs, function(p) { + on_cran <- isTRUE(p$`_cranurl`) + list( + package = p$Package, + on_cran = on_cran, + downloads = if (on_cran) p$`_downloads`$count %||% NA else NA + ) + }) + ## registry names and repo names differ in case (RBi vs rbi) + purrr::set_names(stats, tolower(purrr::map_chr(stats, "package"))) +} + +## Packages listed from outside the epiforecasts registry have no entry there, +## so ask cranlogs directly. A package absent from CRAN returns a zero, which +## is why the answer is only trusted when crandb knows the package. +cranlogs_downloads <- function(package) { + known <- tryCatch( + !is.null( + jsonlite::fromJSON(paste0("https://crandb.r-pkg.org/", package))$Package + ), + error = function(err) FALSE + ) + if (!isTRUE(known)) return(list(on_cran = FALSE, downloads = NA_integer_)) + + res <- tryCatch( + jsonlite::fromJSON( + paste0("https://cranlogs.r-pkg.org/downloads/total/last-month/", package) + ), + error = function(err) NULL + ) + count <- if (is.null(res) || length(res$downloads) == 0) { + NA_integer_ + } else { + as.integer(res$downloads[[1]]) + } + list(on_cran = TRUE, downloads = count) +} + +collect_package_stats <- function(on_date = Sys.Date()) { + repos <- package_repos() + downloads <- universe_downloads() + + rows <- purrr::map(repos, function(repo) { + package <- basename(repo) + gh_stats <- github_stats(repo) + dl <- downloads[[tolower(package)]] %||% cranlogs_downloads(package) + + data.frame( + date = as.character(on_date), + package = package, + repo = repo, + stars = gh_stats$stars, + forks = gh_stats$forks, + open_issues = gh_stats$issues, + on_cran = dl$on_cran, + downloads_last_month = dl$downloads, + stringsAsFactors = FALSE + ) + }) + + dplyr::bind_rows(rows) +} + +## ---- papers ---------------------------------------------------------------- + +dois_from_papers <- function(bib = "_data/papers.bib") { + lines <- readLines(bib, warn = FALSE) + pattern <- "DOI = \\{[^}]+\\}" + hits <- regmatches(lines, regexpr(pattern, lines, ignore.case = TRUE)) + unique(gsub("DOI = \\{|\\}", "", hits, ignore.case = TRUE)) +} + +collect_paper_citations <- function(on_date = Sys.Date()) { + dois <- dois_from_papers() + rows <- purrr::map(dois, function(doi) { + info <- openalex_citations(doi) + data.frame( + date = as.character(on_date), + doi = doi, + title = info$title, + year = info$year, + citations = info$citations, + stringsAsFactors = FALSE + ) + }) + dplyr::bind_rows(rows) +} + +## ---- writing --------------------------------------------------------------- + +## Append this week's rows, keeping one row per subject per date so a rerun on +## the same day corrects rather than duplicates. +append_stats <- function(new_rows, path, key) { + if (nrow(new_rows) == 0) return(invisible(NULL)) + combined <- if (file.exists(path)) { + old <- utils::read.csv(path, stringsAsFactors = FALSE) + dplyr::bind_rows(old, new_rows) + } else { + new_rows + } + combined <- combined |> + dplyr::distinct( + dplyr::across(dplyr::all_of(c("date", key))), + .keep_all = TRUE + ) |> + dplyr::arrange(date, .data[[key]]) + utils::write.csv(combined, path, row.names = FALSE) + invisible(combined) +} diff --git a/_data/package-stats.csv b/_data/package-stats.csv new file mode 100644 index 0000000..9790601 --- /dev/null +++ b/_data/package-stats.csv @@ -0,0 +1,17 @@ +"date","package","repo","stars","forks","open_issues","on_cran","downloads_last_month" +"2026-08-25","EpiNow2","epiforecasts/EpiNow2",140,40,66,TRUE,598 +"2026-08-25","RBi","sbfnk/RBi",25,8,0,TRUE,197 +"2026-08-25","baselinenowcast","epinowcast/baselinenowcast",10,2,63,TRUE,192 +"2026-08-25","cfrnow","epiforecasts/cfrnow",2,0,0,FALSE,NA +"2026-08-25","contactsurveys","epiforecasts/contactsurveys",2,3,0,TRUE,263 +"2026-08-25","distspec","epiforecasts/distspec",1,0,7,TRUE,164 +"2026-08-25","epichains","epiverse-trace/epichains",10,4,33,TRUE,229 +"2026-08-25","epimixr","sbfnk/epimixr",4,0,5,FALSE,NA +"2026-08-25","epinowcast","epinowcast/epinowcast",67,22,120,FALSE,NA +"2026-08-25","forecastbaselines","epiforecasts/forecastbaselines",0,0,2,FALSE,NA +"2026-08-25","lopensemble","epiforecasts/lopensemble",7,3,8,FALSE,NA +"2026-08-25","qrensemble","epiforecasts/qrensemble",3,0,5,FALSE,NA +"2026-08-25","rbi.helpers","sbfnk/rbi.helpers",4,2,0,TRUE,161 +"2026-08-25","ringbp","epiforecasts/ringbp",18,12,16,TRUE,161 +"2026-08-25","scoringutils","epiforecasts/scoringutils",64,24,119,TRUE,934 +"2026-08-25","socialmixr","epiforecasts/socialmixr",44,15,1,TRUE,903 diff --git a/_data/paper-citations.csv b/_data/paper-citations.csv new file mode 100644 index 0000000..c548a6b --- /dev/null +++ b/_data/paper-citations.csv @@ -0,0 +1,40 @@ +"date","doi","title","year","citations" +"2026-08-25","10.1016/j.epidem.2024.100765","Characterising information gains and losses when collecting multiple epidemic model outputs",2024,11 +"2026-08-25","10.1016/j.idm.2025.04.005","Visual preferences for communicating modelling: a global analysis of COVID-19 policy and decision makers",2025,4 +"2026-08-25","10.1016/s2214-109x(20)30074-7","Feasibility of controlling COVID-19 outbreaks by isolation of cases and contacts",2020,2830 +"2026-08-25","10.1038/s41467-021-22213-0","Implications of the school-household network structure on SARS-CoV-2 transmission under school reopening strategies in England",2021,43 +"2026-08-25","10.1038/s41467-021-25207-0","A pre-registered short-term forecasting study of COVID-19 in Germany and Poland during the second wave",2021,86 +"2026-08-25","10.1073/pnas.2203019119","Simulating respiratory disease transmission within and between classrooms to assess pandemic management strategies at schools",2022,11 +"2026-08-25","10.1098/rsfs.2025.0007","Improving modelling for epidemic response: a progress update from a community of UK infectious disease modellers",2025,0 +"2026-08-25","10.1098/rsif.2020.0084","Probabilistic reconstruction of measles transmission clusters from routinely collected surveillance data",2020,12 +"2026-08-25","10.1098/rstb.2020.0283","Exploring surveillance data biases when estimating the reproduction number: with insights into subpopulation transmission of COVID-19 in England",2021,52 +"2026-08-25","10.1101/2022.01.08.22268920","Estimation of the test to test distribution as a proxy for generation interval distribution for the Omicron variant in England",2022,68 +"2026-08-25","10.1101/2022.10.12.22280917","Evaluating an epidemiologically motivated surrogate model of a multi-model ensemble",2022,2 +"2026-08-25","10.1101/2025.03.04.25323088","The utility of infectious disease modelling in informing policy for outbreak response: a scoping review",2025,0 +"2026-08-25","10.1101/2025.04.03.25325159","Fine-Grid Spatial Interaction Matrices for Surveillance Models, with Application to Influenza in Germany",2025,0 +"2026-08-25","10.1101/2025.04.10.25325611","The influence of model structure and geographic specificity on predictive accuracy among European COVID-19 forecasts",2025,1 +"2026-08-25","10.1101/2025.08.01.25332807","Mind the Baseline: The Hidden Impact of Reference Model Selection on Forecast Assessment",2025,2 +"2026-08-25","10.1101/2025.08.09.25331484","The influence of ensemble size and composition on the performance of combined real-time COVID-19 forecasts",2025,0 +"2026-08-25","10.1111/rssa.12974","Sebastian Funk, Sam Abbott and Johannes Bracher’s Discussion Contribution to the Papers in Session 2 of The Royal Statistical Society’s Special Topic Meeting on Covid-19 Transmission: 11 June 2021",2022,2 +"2026-08-25","10.1126/science.abf9648","The impact of population-wide rapid antigen testing on SARS-CoV-2 prevalence in Slovakia",2021,195 +"2026-08-25","10.1126/science.add4507","Heavy-tailed sexual contact networks and monkeypox epidemiology in the global outbreak, 2022",2022,220 +"2026-08-25","10.1186/s12916-019-1288-7","Real-time analysis of the diphtheria outbreak in forcibly displaced Myanmar nationals in Bangladesh",2019,56 +"2026-08-25","10.1186/s12916-022-02271-x","Comparative assessment of methods for short-term forecasts of COVID-19 hospital admissions in England at the local level",2022,34 +"2026-08-25","10.12688/wellcomeopenres.15718.1","The transmissibility of novel Coronavirus in the early stages of the 2019-20 outbreak in Wuhan: Exploring initial point-source exposure sizes and durations using scenario analysis",2020,83 +"2026-08-25","10.12688/wellcomeopenres.15842.3","Estimating the overdispersion in COVID-19 transmission using outbreak sizes outside China",2020,676 +"2026-08-25","10.12688/wellcomeopenres.16006.2","Estimating the time-varying reproduction number of SARS-CoV-2 using national and subnational case counts",2020,195 +"2026-08-25","10.12688/wellcomeopenres.16344.3","Implication of backward contact tracing in the presence of overdispersed transmission in COVID-19 outbreaks",2021,91 +"2026-08-25","10.12688/wellcomeopenres.19380.2","Human judgement forecasting of COVID-19 in the UK",2024,3 +"2026-08-25","10.12688/wellcomeopenres.19601.1","Improving modelling for epidemic responses: reflections from members of the UK infectious disease modelling community on their experiences during the COVID-19 pandemic",2024,15 +"2026-08-25","10.12688/wellcomeopenres.25027.2","Baseline nowcasting methods for handling delays in epidemiological data",2026,1 +"2026-08-25","10.1371/currents.outbreaks.406ae55e83ec0b5193e30856b9235ed2","Temporal Changes in Ebola Transmission in Sierra Leone and Implications for Control Requirements: a Real-time Modelling Study",2015,131 +"2026-08-25","10.1371/journal.pcbi.1006785","Assessing the performance of real-time epidemic forecasts: A case study of Ebola in the Western Area region of Sierra Leone, 2014-15",2019,114 +"2026-08-25","10.1371/journal.pcbi.1010405","Comparing human and model-based forecasts of COVID-19 in Germany and Poland",2022,31 +"2026-08-25","10.1371/journal.pcbi.1011393","Scoring epidemiological forecasts on transformed scales",2023,50 +"2026-08-25","10.1371/journal.pcbi.1011453","Evaluating the use of social contact data to produce age-specific short-term forecasts of SARS-CoV-2 incidence in England",2023,14 +"2026-08-25","10.1371/journal.pgph.0004675","How does policy modelling work in practice? A global analysis on the use of epidemiological modelling in health crises",2025,9 +"2026-08-25","10.1371/journal.pgph.0005120","The utility of infectious disease modelling in informing decisions for outbreak response: A scoping review",2025,2 +"2026-08-25","10.21105/joss.03290","covidregionaldata: Subnational data for COVID-19 epidemiology",2021,13 +"2026-08-25","10.7554/elife.70767","Inference of the SARS-CoV-2 generation time using UK household data",2022,73 +"2026-08-25","10.7554/elife.81916","Predictive performance of multi-model ensemble forecasts of COVID-19 across European nations",2023,109 +"2026-08-25","10.7554/elife.98005.3","Forecasting the spatial spread of an Ebola epidemic in real time: Comparing predictions of mathematical models and experts",2025,1 diff --git a/_software-item.Rmd b/_software-item.Rmd index ad78816..ba77b74 100644 --- a/_software-item.Rmd +++ b/_software-item.Rmd @@ -23,6 +23,9 @@ if ("{{language}}" != "" && "{{language}}" != "NA") { if ("{{updated}}" != "" && "{{updated}}" != "NA") { meta <- c(meta, paste0("Updated ", format(as.Date("{{updated}}"), "%b %Y"))) } +if ("{{downloads_text}}" != "") { + meta <- c(meta, "{{downloads_text}}") +} if (length(meta) > 0) { cat('
\n') } diff --git a/snippets/update_stats.R b/snippets/update_stats.R new file mode 100644 index 0000000..0152b63 --- /dev/null +++ b/snippets/update_stats.R @@ -0,0 +1,21 @@ +## Weekly metrics for the group's packages and papers. +## +## Appends one row per package and per paper to two CSVs under _data/. The +## history is the point: these numbers cannot be reconstructed later, so the +## job records them as it goes. + +source("_automation/get_stats.R") + +`%||%` <- function(x, y) if (is.null(x)) y else x + +today <- Sys.Date() + +message("Collecting package metrics") +packages <- collect_package_stats(today) +message(sprintf(" %d packages", nrow(packages))) +append_stats(packages, "_data/package-stats.csv", key = "package") + +message("Collecting paper citations") +papers <- collect_paper_citations(today) +message(sprintf(" %d papers", nrow(papers))) +append_stats(papers, "_data/paper-citations.csv", key = "doi") diff --git a/software.qmd b/software.qmd index f7ef50b..f991125 100644 --- a/software.qmd +++ b/software.qmd @@ -14,6 +14,20 @@ library(magrittr) stale_months <- 24 ``` +```{r load-stats} +# Weekly download figures, collected by .github/workflows/update-stats.yaml. +# Absent until that job has run at least once, so treat it as optional. +downloads_by_package <- list() +stats_file <- "_data/package-stats.csv" +if (file.exists(stats_file)) { + stats <- read.csv(stats_file, stringsAsFactors = FALSE) + stats <- stats[stats$date == max(stats$date), ] + stats <- stats[!is.na(stats$downloads_last_month), ] + # registry and repo names differ in case (rbi vs RBi) + downloads_by_package <- split(stats$downloads_last_month, tolower(stats$package)) +} +``` + ```{r load-team} # Load team members and create lookup by GitHub username team <- fs::dir_ls("_data/team", regexp = "\\w+\\-\\w+\\.yml") |> @@ -216,9 +230,22 @@ all_packages %>% ) } + downloads <- downloads_by_package[[tolower(e$Package)]] + downloads_text <- if (is.null(downloads)) { + "" + } else { + # name the source, so a package without a figure reads as "not on CRAN" + # rather than "not used" + paste( + formatC(downloads, big.mark = ",", format = "d"), + "CRAN downloads/month" + ) + } + knitr::knit_expand( "_software-item.Rmd", Package = e$Package, + downloads_text = downloads_text, logo_html = logo_html, part_of_html = part_of_html, description = e$description,