Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/update-stats.yaml
Original file line number Diff line number Diff line change
@@ -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
189 changes: 189 additions & 0 deletions _automation/get_stats.R
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 17 additions & 0 deletions _data/package-stats.csv
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions _data/paper-citations.csv
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions _software-item.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div class="project-meta">', paste(meta, collapse = " · "), '</div>\n')
}
Expand Down
21 changes: 21 additions & 0 deletions snippets/update_stats.R
Original file line number Diff line number Diff line change
@@ -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")
27 changes: 27 additions & 0 deletions software.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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") |>
Expand Down Expand Up @@ -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,
Expand Down