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
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Collate:
'Transpose.R'
'V5Compatibility.R'
'V5LayerSupport.R'
'VerifyH5AD.R'
'WriteH5AD.R'
'ZarrRemote.R'
'ZarrStore.R'
Expand Down
67 changes: 67 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,72 @@
# scConvert 0.3.0 (development)

## Reverse-conversion (h5ad -> Seurat) integrity overhaul

`readH5AD()` now applies the same read-back discipline to the reverse
direction that the write direction already learned the hard way. New
helpers live in `R/VerifyH5AD.R`; regression coverage in
`tests/testthat/test-reverse-conversion.R`.

- **Version/layout-aware counts resolution.** Where the raw counts live is
now resolved explicitly, in priority order: the `/uns/scConvert/
counts_location` stamp (see below), `/layers/counts` (modern
anndata/scanpy convention), `/raw/X` (legacy scanpy), then `/X`. The
resolved slot is loaded into the Seurat `counts` layer and X into `data`
in every path -- in-memory, `components` subsets, the C-reader fallback,
and BPCells on-disk mode (which previously only knew about `raw/X` and
silently served log-normalized X as on-disk "counts" for
`layers['counts']` files). Relocating counts out of `layers/counts` is
now a hard `scConvert_data_error` on failure instead of a swallowed
message that left log-normalized values sitting in the counts slot.

- **Counts integrality guard.** If the values that end up in the counts
layer are non-integer, `readH5AD()` raises a classed
`scConvert_counts_warning` naming the slot they came from, instead of
silently handing normalized data to downstream steps that assume raw
counts.

- **Writer provenance stamp.** All h5ad writers (`.writeH5AD_c`,
`DirectSeuratToH5AD`, `H5SeuratToH5AD`) now stamp
`/uns/scConvert/{version, counts_location}` by inspecting the file just
written -- not the writer's intent -- so future reads branch on recorded
fact rather than layout heuristics.

- **Post-read verification.** Before returning, the loaded object is
asserted against the file: dims must match the file's obs/var counts
(transpose/orientation check), cell names must equal the obs index in
order, feature names must equal the var index modulo Seurat's documented
underscore-to-dash replacement, and no duplicate barcodes or genes may
survive. Violations raise `scConvert_data_error`. What the reader did
(counts source, where X went, scConvert version, dedup flags) is
recorded in `misc$scConvert_read`.

- **Duplicate names are loud.** Duplicate cell barcodes or feature names
in the file are made unique with a classed `scConvert_names_warning`
(previously features were deduplicated silently and duplicate barcodes
fell through to Seurat with undefined downstream alignment).

- **Gene identity is never silently lost.** When final feature names
differ from the file's var index (deduplication or Seurat's underscore
mangling), the original identifiers are preserved in a new
`orig_var_index` feature-metadata column. var metadata columns are now
keyed by the object's final rownames, fixing silent misalignment (and
layer drops) for files with underscores in gene names.

- **Reduction key collisions and remapping.** obsm keys that clean to the
same reduction name (e.g. `X_pca` and `pca`) no longer silently
overwrite each other; later claimants keep their raw obsm key (made
unique if needed) with a `scConvert_reduction_warning`. A new
`reductions` argument to `readH5AD()` selects a subset of obsm keys
and/or renames them (`reductions = c(scvi = "X_scVI")`), replacing blind
full-object import with explicit key remapping.

- **Categorical order and orderedness round-trip.** `DecodeCategorical()`
keeps the stored category order as the factor level order (now pinned by
tests against non-alphabetical orders) and gains an `ordered` argument;
both hdf5r reader paths read the AnnData `ordered` flag, and the
C-reader path restores it post-hoc, so ordered pandas categoricals come
back as ordered factors.

## New features

- **`readZarr()` index slicing: `obs_idx` and `var_idx` push down to
Expand Down
12 changes: 10 additions & 2 deletions R/AnnDataEncoding.R
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@ NULL

#' Decode AnnData categorical encoding to R factor
#'
#' The factor levels follow the stored category order verbatim (pandas
#' preserves an explicit category order; re-sorting it alphabetically would
#' silently reorder positional palettes and any order-dependent downstream
#' logic). When the AnnData categorical carries \code{ordered = TRUE}, pass
#' it here to get an ordered factor back.
#'
#' @param codes Integer vector of 0-based category codes (-1 = NA)
#' @param categories Character vector of category labels
#' @param ordered Logical; produce an ordered factor (AnnData's
#' \code{ordered} categorical flag). Default \code{FALSE}.
#'
#' @return A factor vector
#'
#' @keywords internal
#'
DecodeCategorical <- function(codes, categories) {
DecodeCategorical <- function(codes, categories, ordered = FALSE) {
codes[codes == -1L] <- NA_integer_
valid <- !is.na(codes) & codes >= 0L & codes < length(categories)
if (!all(valid[!is.na(codes)])) {
Expand All @@ -26,7 +34,7 @@ DecodeCategorical <- function(codes, categories) {
))
codes[!is.na(codes) & !valid] <- NA_integer_
}
factor(categories[codes + 1L], levels = categories)
factor(categories[codes + 1L], levels = categories, ordered = isTRUE(ordered))
}

#' Encode R factor as AnnData categorical
Expand Down
5 changes: 5 additions & 0 deletions R/Convert.R
Original file line number Diff line number Diff line change
Expand Up @@ -4023,6 +4023,11 @@ H5SeuratToH5AD <- function(
}
}

# Provenance stamp: /uns/scConvert/{version, counts_location}, derived by
# inspecting the file just written, so readers resolve the counts layer
# from a version-aware fact instead of layout heuristics.
.h5ad_stamp_provenance(dfile)

dfile$flush()
return(dfile)
}
Expand Down
Loading
Loading