Skip to content
Merged
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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Type: Package
Package: cellNexus
Title: Queries the Human Cell Atlas
Version: 0.99.29
Version: 0.99.30
Authors@R: c(
person(
"Stefano",
Expand Down
137 changes: 132 additions & 5 deletions R/counts.R
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
#' Base URL pointing to the count data at the current version
#' @keywords internal
#' @noRd
COUNTS_URL <- paste0(

Check warning on line 17 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Variable and function name style should match snake_case or symbols.
"https://object-store.rc.nectar.org.au/v1/",
"AUTH_06d6e008e3e642da99d806ba3ea629c5/cellNexus-anndata"
)
Expand All @@ -30,8 +30,8 @@
#' and S. Mangiola. "cellNexus: Quality control, annotation, aggregation
#' and analytical layers for the Human Cell Atlas data." bioRxiv (2026).
#' doi:10.64898/2026.04.14.718336.
#' @source [Shen et al.,2026](https://www.biorxiv.org/content/10.64898/2026.04.14.718336v3)

Check warning on line 33 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Lines should not be more than 80 characters. This line is 91 characters.
get_SingleCellExperiment <- function(...) {

Check warning on line 34 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Variable and function name style should match snake_case or symbols.
cli_alert_warning(paste(
"This function name is deprecated.",
"Please use {.fun get_single_cell_experiment} instead"
Expand All @@ -45,16 +45,16 @@
#' Given a data frame of Curated Atlas metadata obtained from [get_metadata()],
#' returns a [`SingleCellExperiment::SingleCellExperiment-class`] object
#' corresponding to the samples in that data frame
#' @param data A data frame containing, at minimum, `cell_id`, `file_id_cellNexus_single_cell`

Check warning on line 48 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Lines should not be more than 80 characters. This line is 94 characters.
#' and `atlas_id` columns, which correspond to a single cell ID, file subdivision for internal use,

Check warning on line 49 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Lines should not be more than 80 characters. This line is 101 characters.
#' and atlas name in format (e.g cellxgene_2024/0.1.0) for internal use.
#' They can be obtained from the [get_metadata()] function.
#' Use `get_atlas_versions()` to download atlas versions data frame.
#' @param assays A character vector specifying the desired assay(s) to be requested.

Check warning on line 53 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Lines should not be more than 80 characters. This line is 84 characters.
#' Valid elements include "counts", "cpm", "rank", and "sct" for single-cell analyses

Check warning on line 54 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Lines should not be more than 80 characters. This line is 87 characters.
#' The default setting retrieves only the counts assay.
#' If your analysis involves a smaller set of genes, consider using the "cpm" assay.

Check warning on line 56 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Lines should not be more than 80 characters. This line is 86 characters.
#' The "rank" assay is suited for signature calculations across millions of cells.

Check warning on line 57 in R/counts.R

View workflow job for this annotation

GitHub Actions / ubuntu-latest (auto)

[lintr] Lines should not be more than 80 characters. This line is 84 characters.
#' @param cell_aggregation A character vector that specifies which cell aggregation
#' strategy should be applied. This will create a corresponding subdirectory
#' in the cache directory. Single cell level is applied by default.
Expand Down Expand Up @@ -165,7 +165,7 @@
#' feature rownames; `get_pseudobulk()` restores them after coercion.
#' @details
#' Columns in `data` that are constant within each
#' `sample_id` × `cell_type_unified_ensemble` combination (including
#' `sample_id` x `cell_type_unified_ensemble` combination (including
#' user-added annotations) are retained in `colData`. Cell-level columns are
#' dropped via internal `keep_specific_annotation_columns()`.
#' @return By default, a `SingleCellExperiment` object. If
Expand Down Expand Up @@ -342,6 +342,85 @@
)
}

#' Skip groups whose SCT h5ad file is absent from the local cache
#'
#' SCT normalisation can fail for entire samples (too few cells, extreme count
#' distributions, etc.), in which case no SCT h5ad file is written to the
#' store. \code{file_id_cellNexus_single_cell} is always pre-computed and
#' populated, so missing files are only detectable after sync via
#' \code{file.exists()}. This helper removes those groups \emph{before} the
#' per-assay read loop so that all assay experiment lists stay aligned (same
#' length, same order).
#'
#' Because the metadata carries no pre-computed flag for SCT failures, users
#' cannot identify the affected samples before calling
#' \code{get_single_cell_experiment()}. The warning therefore instructs them to
#' compare their input \code{sample_id}s with those in
#' \code{SummarizedExperiment::colData()} of the returned object.
#'
#' @param groups A list of per-file metadata tibbles from
#' \code{dplyr::group_split()}.
#' @param cache_directory Root local cache directory (scalar character).
#' @param cell_aggregation Aggregation level string (e.g. \code{""} for
#' single-cell).
#' @param grouping_column Name of the file-ID column used to build the path.
#' @return The filtered \code{groups} list (only entries whose SCT file exists
#' in the cache). Calls \code{cli_abort()} when no groups remain.
#' @importFrom purrr map_lgl map
#' @importFrom cli cli_alert_warning cli_abort
#' @keywords internal
#' @noRd
.skip_groups_missing_sct_file <- function(groups, cache_directory, cell_aggregation, grouping_column) {
sct_base_path <- if (nchar(cell_aggregation) > 0) {
file.path(cell_aggregation, assay_map[["sct"]])
} else {
assay_map[["sct"]]
}

has_file <- map_lgl(groups, function(gr) {
file.exists(file.path(
cache_directory, gr$atlas_id[1], sct_base_path, gr[[grouping_column]][1]
))
})

if (all(has_file)) {
return(groups)
}

skipped <- groups[!has_file]
n_cells_skipped <- sum(vapply(skipped, nrow, integer(1L)))
skipped_sample_ids <- if (all(map_lgl(skipped, ~ "sample_id" %in% names(.x)))) {
unique(unlist(map(skipped, ~ unique(.x$sample_id))))
} else {
character(0)
}
n_samples_skipped <- length(skipped_sample_ids)

example_str <- if (n_samples_skipped > 0L) {
paste0(
" Example affected sample_id(s): ",
paste(head(skipped_sample_ids, 3L), collapse = ", "), "."
)
} else {
""
}

cli_alert_warning(paste(
"cellNexus says: {n_cells_skipped} cell(s) from {n_samples_skipped} sample(s)",
"are missing their SCT h5ad file and will be skipped.",
"SCT normalisation can fail for samples with too few cells or extreme count",
"distributions -- in those cases no SCT file is written to the store.",
"After retrieval, identify skipped samples with:",
"`dplyr::setdiff(unique(your_metadata$sample_id),",
"unique(SummarizedExperiment::colData(result)$sample_id))`.",
"{example_str}"
))

# Return empty list when nothing remains; the caller decides whether to abort
# (SCT-only query) or fall back to other assays (mixed-assay query).
groups[has_file]
}

#' Sync, read, filter and compile experiments from cache
#'
#' Shared internal implementation used by [get_single_cell_experiment()],
Expand Down Expand Up @@ -407,9 +486,25 @@
)
})

# Combine all file lists and download in one parallel batch
# Combine all file lists, then sync with assay-appropriate strictness.
# SCT files may legitimately be absent from the cloud (normalisation failed
# for that sample), so HTTP 404 is treated as "not available" rather than
# an error. All other assay files are synced strictly.
all_files <- do.call(rbind, file_lists)
sync_all_assay_files(all_files)
sct_subdir <- assay_map[["sct"]]
is_sct_file <- grepl(
paste0("/", sct_subdir, "/"), all_files$full_url,
fixed = TRUE
)
if (any(!is_sct_file)) {
sync_all_assay_files(all_files[!is_sct_file, , drop = FALSE])
}
if (any(is_sct_file)) {
sync_all_assay_files(
all_files[is_sct_file, , drop = FALSE],
ignore_not_found = TRUE
)
}
}

cli_alert_info("Reading files.")
Expand All @@ -418,6 +513,37 @@
groups <- raw_data |>
group_split(.data[[grouping_column]])

# For SCT, some samples may have no h5ad file (normalisation failed).
# Drop those groups now so all per-assay experiment lists stay aligned.
if ("sct" %in% assays) {
groups_with_sct <- .skip_groups_missing_sct_file(
groups, cache_directory, cell_aggregation, grouping_column
)

if (length(groups_with_sct) == 0L) {
# No SCT files at all. When other assays were also requested, fall back to
# returning those for ALL groups. When SCT was the only assay, abort.
other_assays <- setdiff(assays, "sct")
if (length(other_assays) == 0L) {
cli_abort(paste(
"cellNexus says: No groups remain after removing samples with missing SCT files.",
"SCT normalisation appears to have failed for every sample in your query.",
"Try a different set of samples or use assays = \"counts\" instead."
))
}
cli_alert_warning(paste(
"cellNexus says: No SCT files were found for any sample in your query.",
"Falling back to returning only: {paste(other_assays, collapse = ', ')}.",
"The 'sct' assay will not be present in the returned object."
))
assays <- other_assays
subdirs <- assay_map[assays]
# Keep the full unfiltered `groups` so all cells are returned.
} else {
groups <- groups_with_sct
}
}

# Outer imap iterates over assays; each assay gets its own labelled progress bar.
# This also fixes dir_prefix construction: current_subdir is properly scoped here.
experiments <- imap(subdirs, function(current_subdir, current_assay) {
Expand Down Expand Up @@ -903,7 +1029,7 @@
#' @param file_list A data frame with full_url and output_file columns
#' @importFrom cli cli_alert_info
#' @noRd
sync_all_assay_files <- function(file_list) {
sync_all_assay_files <- function(file_list, ignore_not_found = FALSE) {
if (nrow(file_list) == 0) {
return(invisible(character(0)))
}
Expand All @@ -916,7 +1042,8 @@
sync_remote_files(
file_list$full_url[to_download],
file_list$output_file[to_download],
progress = TRUE
progress = TRUE,
ignore_not_found = ignore_not_found
)
}

Expand Down
4 changes: 2 additions & 2 deletions R/metadata.R
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ metadata_aliases <- c(
#' Returns the URLs for all metadata files
#'
#' @param databases A character vector of atlas aliases or raw parquet
#' filenames. Recognised aliases are `"hca_2024"` and `"hca_2025"`.
#' filenames. Recognised aliases are `"hca_2024"`
#' Raw filenames (e.g. `"atlas_versions.parquet"`) are passed through
#' unchanged and are intended for internal package use.
#' @export
Expand All @@ -35,7 +35,7 @@ metadata_aliases <- c(
#' and analytical layers for the Human Cell Atlas data." bioRxiv (2026).
#' doi:10.64898/2026.04.14.718336.
#' @source [Shen et al.,2026](https://www.biorxiv.org/content/10.64898/2026.04.14.718336v3)
get_metadata_url <- function(databases = c("hca_2024", "hca_2025")) {
get_metadata_url <- function(databases = c("hca_2024")) {
dbs <- ifelse(
databases %in% names(metadata_aliases),
metadata_aliases[databases],
Expand Down
29 changes: 26 additions & 3 deletions R/seurat.R
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ as.sparse.DelayedMatrix <- function(x, ...) {
#' @inheritDotParams get_single_cell_experiment
#' @export
#' @return A Seurat object containing the same data as a call to
#' [get_single_cell_experiment()]
#' [get_single_cell_experiment()]. All requested assays are present in the
#' returned object under their original names (e.g. `"counts"`, `"cpm"`).
#' @importFrom SummarizedExperiment assayNames assay
#' @examples
#' # Use the lightweight sample database URL (for fast checks during development only)
#' meta <- get_metadata(cloud_metadata = cellNexus::SAMPLE_DATABASE_URL) |> head(2)
Expand All @@ -29,6 +31,27 @@ as.sparse.DelayedMatrix <- function(x, ...) {
get_seurat <- function(...) {
rlang::check_installed(c("Seurat", "SeuratObject"))

get_single_cell_experiment(...) |>
SeuratObject::as.Seurat(data = NULL)
sce <- get_single_cell_experiment(...)

sce_assays <- assayNames(sce)
first_assay <- sce_assays[1]
seurat_obj <- SeuratObject::as.Seurat(sce, counts = first_assay, data = NULL)
# as.Seurat() always names the first assay "originalexp"; replace it with a
# copy under the real name to avoid the misleading label.
if (first_assay != "originalexp") {
assay_obj <- seurat_obj[["originalexp"]]
SeuratObject::Key(assay_obj) <- paste0(first_assay, "_")
seurat_obj[[first_assay]] <- assay_obj
SeuratObject::DefaultAssay(seurat_obj) <- first_assay
seurat_obj[["originalexp"]] <- NULL
}
# Attach any additional assays that were not part of the initial conversion.
if (length(sce_assays) > 1) {
for (assay_name in sce_assays[-1]) {
seurat_obj[[assay_name]] <- SeuratObject::CreateAssayObject(
counts = assay(sce, assay_name)
)
}
}
seurat_obj
}
25 changes: 19 additions & 6 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ get_default_cache_dir <- function() {
#' @return `NULL`, invisibly
#' @keywords internal
#' @noRd
sync_remote_file <- function(full_url, output_file, overwrite = FALSE, ...) {
sync_remote_file <- function(full_url, output_file, overwrite = FALSE,
ignore_not_found = FALSE, ...) {
if (isTRUE(overwrite) && file.exists(output_file)) {
file.remove(output_file)
}
Expand All @@ -104,8 +105,16 @@ sync_remote_file <- function(full_url, output_file, overwrite = FALSE, ...) {
GET(full_url, write_disk(output_file), ...) |>
stop_for_status(),
error = function(e) {
# Clean up if we had an error
file.remove(output_file)
# Clean up partial download regardless.
if (file.exists(output_file)) file.remove(output_file)
# For SCT assays the file may legitimately not exist (normalisation
# failed for that sample). When the caller opts in, treat HTTP 404 as
# "file not available" rather than a hard error; the file is simply
# absent from the cache and downstream logic handles it.
if (ignore_not_found &&
grepl("Not Found|404", conditionMessage(e), ignore.case = TRUE)) {
return(invisible(NULL))
}
cli_abort("File {full_url} could not be downloaded. {e}")
}
)
Expand All @@ -126,7 +135,8 @@ sync_remote_file <- function(full_url, output_file, overwrite = FALSE, ...) {
#' @return The output_files vector, invisibly
#' @keywords internal
#' @noRd
sync_remote_files <- function(urls, output_files, progress = TRUE) {
sync_remote_files <- function(urls, output_files, progress = TRUE,
ignore_not_found = FALSE) {
if (length(urls) == 0) {
return(invisible(character(0)))
}
Expand Down Expand Up @@ -173,14 +183,17 @@ sync_remote_files <- function(urls, output_files, progress = TRUE) {
if (file.exists(f)) file.remove(f)
}
cli_alert_warning("{sum(failed)} file{?s} failed to download")
if (sum(failed) == length(urls_to_download)) {
if (!ignore_not_found && sum(failed) == length(urls_to_download)) {
cli_abort("All downloads failed. Check your network connection.")
}
}
} else {
# Sequential fallback
for (i in seq_along(urls_to_download)) {
sync_remote_file(urls_to_download[i], files_to_download[i])
sync_remote_file(
urls_to_download[i], files_to_download[i],
ignore_not_found = ignore_not_found
)
}
}

Expand Down
37 changes: 37 additions & 0 deletions inst/NEWS.Rd
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
\name{NEWS}
\title{News for Package \pkg{cellNexus}}

\section{News in version 0.99.30}{
\itemize{
\item \code{get_seurat()} now correctly handles multiple assays and non-default
first assays.
\itemize{
\item When \code{assays = c("counts", "cpm")} (or any multi-assay request),
all assays are transferred to the returned Seurat object: the first assay
occupies the \code{"originalexp"} slot and every subsequent assay is added
under its own name (e.g. \code{"cpm"}).
\item When the first requested assay is not \code{"counts"} (e.g.
\code{assays = "cpm"}), the conversion no longer errors with
\emph{"No data in provided assay - counts"}. The first assay in the SCE is
now passed explicitly to \code{SeuratObject::as.Seurat()}.
}
\item SCT assay: robust handling of samples for which SCT normalisation failed.
\itemize{
\item HTTP 404 responses for SCT h5ad files are now treated as
\emph{"file not available"} rather than a fatal download error. The SCT
sync path uses \code{ignore_not_found = TRUE} while all other assay syncs
remain strict.
\item A new internal helper \code{.skip_groups_missing_sct_file()} detects
cached-file absence after sync, emits a \code{cellNexus says:} warning with
the affected cell/sample counts and up to three example \code{sample_id}s,
and instructs users to identify skipped samples post-retrieval via
\code{dplyr::setdiff(unique(your_metadata\$sample_id),
unique(SummarizedExperiment::colData(result)\$sample_id))}.
\item When \code{assays = "sct"} only and \emph{every} sample is missing its
SCT file, \code{get_single_cell_experiment()} aborts with a clear message.
\item When \code{assays = c("counts", "sct")} (or any mixed-assay request)
and \emph{every} sample is missing its SCT file, the function falls back
gracefully to returning the non-SCT assays for all cells with a warning,
rather than aborting.
}
\item Tests. Added coverage for \code{get_seurat()} multi-assay and
non-default-assay paths.
}}

\section{News in version 0.99.27}{
\itemize{
\item \code{get_pseudobulk()} now retains metadata columns that are constant within each
Expand Down
4 changes: 2 additions & 2 deletions man/get_metadata_url.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion man/get_pseudobulk.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion man/get_seurat.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading