Skip to content

Commit a8b9cd8

Browse files
authored
Merge pull request #170 from Alamar-Biosciences/release/v1.4.2
Release v1.4.2
2 parents 030bae3 + 4071540 commit a8b9cd8

38 files changed

+27656
-296
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,14 @@ vignettes/*.pdf
4444

4545
# Example HTML file generated by skeleton.Rmd
4646
inst/rmarkdown/templates/nulisaseq/skeleton/skeleton.html
47+
inst/rmarkdown/templates/nulisaseq/skeleton/outputFiles
4748
inst/doc
4849
/doc/
4950
/Meta/
5051

52+
# outputFiles generated by tests
53+
tests/testthat/fixtures/outputFiles
54+
5155
# Book build artifacts (don't commit these)
5256
vignettes/book/_book/
5357
vignettes/book/_bookdown_files/
@@ -61,3 +65,5 @@ vignettes/book/_main.Rmd
6165
CLAUDE.md
6266
*.bak
6367
docs/
68+
.claude/
69+

DESCRIPTION

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Type: Package
22
Package: NULISAseqR
33
Title: Analysis of NULISAseq Data
4-
Version: 1.4.1
4+
Version: 1.4.2
55
Authors@R:
66
person("Alamar Biosciences Bioinformatics Team", , , "bioinfo@alamarbio.com", role = c("aut", "cre", "cph"),
77
comment = "Contributors: Dwight Kuo, Joanne C. Beer, Eliza Chai, Sumedh Sankhe, Kasun Buddika")
@@ -62,7 +62,8 @@ Suggests:
6262
renv,
6363
rmarkdown,
6464
shiny,
65-
testthat (>= 3.0.0)
65+
testthat (>= 3.0.0),
66+
withr
6667
VignetteBuilder:
6768
knitr
6869
Config/testthat/edition: 3

NEWS.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
1+
# NULISAseqR 1.4.2 (2026-02-15)
2+
3+
## Changes
4+
5+
### Enhancements
6+
* **render_QC_report()** - Improved function parameter ordering and defaults:
7+
- `xml_files` parameter moved to first position for more intuitive usage
8+
- Added default values for `output_filename` ("NULISAseq_QC_Report.html") and `output_dir` (current working directory)
9+
- Added default value for `dataDir` (current working directory)
10+
- Simplified `Rmd_input_file` path construction using `system.file()`
11+
* **lod()** - Enhanced documentation and parameter handling:
12+
- Improved parameter ordering (moved `data_matrix` before `blanks`)
13+
- Enhanced roxygen documentation with clearer return value descriptions
14+
- Added filtering to ensure `targetNoOutlierDetection` only includes targets present in `data_matrix`
15+
16+
### Bug Fixes
17+
* **quantifiability()** - Fixed sample subsetting issue that could cause errors when sample lists don't match between AQ data and sample information:
18+
- Now uses `intersect()` to find common samples between `Data_AQ_aM` and `SampleNames`
19+
- Correctly calculates sample counts for overall and subgroup quantifiability
20+
- Prevents errors when processing data with mismatched sample lists
21+
* **loadNULISAseq()** - Added calculation of `LOD_pgmL` (limit of detection in pg/mL units) from XML data for AQ assays
22+
* **targetBoxplot()** - Fixed parameter naming in `lod()` function call to use `data_matrix=` explicitly
23+
24+
### Testing
25+
* **New comprehensive test suites** added to ensure code quality and reliability:
26+
- `test-importNULISAseq.R` - Tests for `importNULISAseq()` function with and without NULISAseqAQ package, including fallback mode validation and AQ data consistency checks
27+
- `test-reverse-curve.R` - Tests for reverse curve target handling, including correlation validation, data transformation verification, and NPQ value consistency between `loadNULISAseq()` and `importNULISAseq()`
28+
- `test-writeNULISAseq.R` - Tests for Excel output generation with both RQ-only and AQ data, including validation of sheet structure, column names, and specific data values
29+
* **Test infrastructure improvements**:
30+
- Moved test fixtures from `inst/rmarkdown/templates/nulisaseq/skeleton/` to `tests/testthat/fixtures/` for better organization
31+
- Removed unnecessary `.gitignore` file from skeleton template directory
32+
33+
---
34+
135
# NULISAseqR 1.4.1 (2026-01-16)
236

337
## Changes

R/lod.R

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@ outliers_index_mad <- function(col, min_blanks = 4, threshold = 2.5) {
2222
#'
2323
#' Calculates limit of detection (LoD) for each target based on the
2424
#' negative controls (blanks). LoD = mean(blanks) + 3*SD(blanks).
25+
#' LoD is typically calculated on IC-IPC normalized reads (unlogged).
2526
#' Designates data as either above or below LoD.
2627
#' Option to specify minimum count threshold for detectability.
2728
#'
28-
#' @param blanks Column indices or column names of the blanks in the
29-
#' data_matrix.
3029
#' @param data_matrix The Data matrix output from readNULISAseq.R
3130
#' or normalized data from normalization functions.
31+
#' @param blanks Column indices or column names of the blanks in the
32+
#' data_matrix.
3233
#' @param min_count Optional count threshold to apply in addition
3334
#' to the LoD. Default is 0.
3435
#' @param min_blank_no Optional numeric parameter defining the minimum number of
@@ -45,14 +46,21 @@ outliers_index_mad <- function(col, min_blanks = 4, threshold = 2.5) {
4546
#' Lists samples/targets that should not be reported
4647
#'
4748
#'
48-
#' @return A list.
49-
#' @param LOD Vector of limits of detection.
50-
#' @param aboveLOD Logical matrix indicating whether counts are
51-
#' above or below LoD for that target.
49+
#' @return A list containing:
50+
#' \item{LOD}{Vector of limits of detection.}
51+
#' \item{aboveLOD}{Logical matrix indicating whether counts are
52+
#' above or below LoD for that target.}
5253
#'
5354
#' @export
5455
#'
55-
lod <- function(data_matrix, blanks, min_count = 0, min_blank_no = 4, mad_threshold = 2.5, ignore_target_blank = NULL, targetNoOutlierDetection = NULL, match_matrix = NULL) {
56+
lod <- function(data_matrix,
57+
blanks,
58+
min_count = 0,
59+
min_blank_no = 4,
60+
mad_threshold = 2.5,
61+
ignore_target_blank = NULL,
62+
targetNoOutlierDetection = NULL,
63+
match_matrix = NULL) {
5664
# Determine blank names if blank indices are provided
5765
if (is.numeric(blanks)) {
5866
blank_names <- colnames(data_matrix)[blanks]
@@ -120,8 +128,10 @@ lod <- function(data_matrix, blanks, min_count = 0, min_blank_no = 4, mad_thresh
120128

121129
# Calculate the blank_mean and blank_sd assuming no outlier detection (blank_meanCount, blank_sdCount)
122130
# replace blank_mean and blank_sd with blank_meanCount / blank_sdCount for targets where no outlier
123-
# deteection is desired
131+
# detection is desired
124132
if(!is.null(targetNoOutlierDetection)){
133+
# filter targetNoOutlierDetection to include only targets in the data_matrix
134+
targetNoOutlierDetection <- targetNoOutlierDetection[targetNoOutlierDetection %in% rownames(data_matrix)]
125135
blank_meanCount <- rowMeans(blank_data, na.rm = TRUE )
126136
blank_sdCount <- apply(blank_data, 1, sd, na.rm = TRUE)
127137
blank_mean[targetNoOutlierDetection] <- blank_meanCount[targetNoOutlierDetection]

R/mergeNULISAseq.R

Lines changed: 124 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ process_loadNULISAseq <- function(data) {
108108
processed_data$aqParams <- processed_data$AQ$targetAQ_param %>%
109109
tibble::as_tibble() %>%
110110
select(!any_of(c("Encrypted", "Decrypted")))
111-
111+
112112
logger::log_info("aqParams found, creating pg/mL matrix")
113113
processed_data$Data_AQ_pgmL <- processed_data[["Data_AQ"]] %>%
114114
tibble::as_tibble(rownames = "targetName") %>%
@@ -122,7 +122,7 @@ process_loadNULISAseq <- function(data) {
122122
names_to = "sampleName") %>%
123123
mutate(
124124
raw = dplyr::if_else(is.na(raw),
125-
raw,
125+
raw,
126126
NULISAseqAQ::unit_convert_am_conc(raw, MW_kDa))) %>%
127127
select(-MW_kDa) %>%
128128
tidyr::pivot_wider(
@@ -131,13 +131,27 @@ process_loadNULISAseq <- function(data) {
131131
values_from = "raw") %>%
132132
column_to_rownames("targetName") %>%
133133
as.matrix()
134-
134+
135135
logger::log_info("Data_AQlog2_pg/mL matrix created")
136-
136+
137137
processed_data$Data_AQlog2_pgmL <- log2(processed_data$Data_AQ_pgmL)
138-
processed_data$AQ_unit <- "pg/mL"
138+
processed_data$AQ_unit <- "pg/mL"
139139
} else{
140-
logger::log_info("Molecular weights in kDa not available, only aM units available")
140+
logger::log_info("Molecular weights in kDa not available, checking for pre-existing pg/mL data")
141+
# Still create aqParams from targetAQ_param even without MW_kDa
142+
processed_data$aqParams <- processed_data$AQ$targetAQ_param %>%
143+
tibble::as_tibble()
144+
145+
# Check if pre-existing Data_AQ (pg/mL) is available from fallback XML
146+
if(!is.null(processed_data$AQ[["Data_AQ"]])){
147+
logger::log_info("Using pre-existing pg/mL matrix from input data")
148+
processed_data$Data_AQ_pgmL <- processed_data$AQ$Data_AQ %>%
149+
rename_cols(., names_df = names_df)
150+
processed_data$Data_AQlog2_pgmL <- log2(processed_data$Data_AQ_pgmL)
151+
processed_data$AQ_unit <- "pg/mL"
152+
} else {
153+
logger::log_info("No pre-existing pg/mL matrix found; only aM units will be available")
154+
}
141155
}
142156
}
143157
}
@@ -304,23 +318,57 @@ process_loadNULISAseq <- function(data) {
304318
processed_data["normed_untransformedReverse"] <- NULL # now called Data_Reverse
305319
processed_data["AQ"] <- NULL
306320

307-
matrices <- c("Data_IC","Data_IClog2","Data_raw","Data_rawlog2","aboveLOD", "Data_AQ", "Data_AQlog2", "withinDR", "Data_Reverse", "Data_Reverselog2","Data_AQ_pgmL","Data_AQlog2_pgmL")
321+
matrices <- c("Data_IC","Data_IClog2","Data_raw","Data_rawlog2","aboveLOD", "Data_AQ", "Data_AQlog2", "Data_Reverse", "Data_Reverselog2","Data_AQ_pgmL","Data_AQlog2_pgmL")
308322
matrices <- matrices[matrices %in% names(processed_data)]
309-
323+
324+
# Track dropped samples for reporting
325+
dropped_samples_info <- list()
326+
310327
for (i in matrices) {
311-
logger::log_info("Removing samples with all NaN or NA values -- ",i)
312-
missing <- apply(processed_data[[i]], 2, function(x) all(is.nan(x)) || all(is.na(x)))
313-
314-
if(i %in% c("Data_AQ", "Data_AQlog2", "Data_AQ_pgmL","Data_AQlog2_pgmL", "withinDR")){
328+
logger::log_info("Removing samples with all NaN or NA values -- ", i)
329+
missing <- apply(processed_data[[i]], 2, function(x) all(is.nan(x)) || all(is.na(x)))
330+
331+
if (sum(missing) > 0) {
332+
dropped_sample_names <- colnames(processed_data[[i]])[missing]
333+
dropped_samples_info[[i]] <- dropped_sample_names
334+
}
335+
336+
if(i %in% c("Data_AQ", "Data_AQlog2", "Data_AQ_pgmL", "Data_AQlog2_pgmL")){
315337
targets <- processed_data[["aqParams"]] %>%
316338
pull(targetName)
317339
} else{
318340
targets <- processed_data[["targets"]] %>%
319-
pull(targetName)
341+
pull(targetName)
320342
}
321-
343+
322344
processed_data[[i]] <- processed_data[[i]][targets, !missing]
323345
}
346+
347+
# Ensure all AQ matrices have consistent rows and columns, then align withinDR
348+
aq_matrices <- c("Data_AQ", "Data_AQlog2", "Data_AQ_pgmL", "Data_AQlog2_pgmL")
349+
aq_matrices <- aq_matrices[aq_matrices %in% names(processed_data)]
350+
351+
if (length(aq_matrices) >= 1) {
352+
common_rows <- Reduce(union, lapply(aq_matrices, function(m) rownames(processed_data[[m]])))
353+
common_cols <- Reduce(union, lapply(aq_matrices, function(m) colnames(processed_data[[m]])))
354+
355+
# Align all AQ matrices to common rows and columns
356+
common_rows <- sort(common_rows)
357+
for (m in aq_matrices) {
358+
processed_data[[m]] <- processed_data[[m]][common_rows, common_cols, drop = FALSE]
359+
}
360+
361+
logger::log_info("AQ matrices aligned to ", length(common_rows), " targets and ", length(common_cols), " samples")
362+
363+
# Align withinDR to match AQ matrices
364+
if ("withinDR" %in% names(processed_data)) {
365+
processed_data$withinDR <- processed_data$withinDR[common_rows, common_cols, drop = FALSE]
366+
logger::log_info("withinDR aligned to AQ matrices")
367+
}
368+
}
369+
370+
# Store dropped samples info for upstream reporting
371+
processed_data$droppedSamples <- dropped_samples_info
324372

325373
processed_data[["samples"]] <- processed_data[["samples"]] %>%
326374
filter(sampleName %in% colnames(processed_data[["Data_IC"]])) %>%
@@ -686,6 +734,61 @@ mergeNULISAseq <- function(dataList, fileNameList, sample_group_covar = "SAMPLE_
686734
}
687735
}
688736

737+
# Collect and report dropped samples from all plates
738+
all_dropped_samples <- list()
739+
for (plate in names(dataList)) {
740+
if (!is.null(dataList[[plate]]$droppedSamples) && length(dataList[[plate]]$droppedSamples) > 0) {
741+
for (matrix_name in names(dataList[[plate]]$droppedSamples)) {
742+
dropped <- dataList[[plate]]$droppedSamples[[matrix_name]]
743+
if (length(dropped) > 0) {
744+
all_dropped_samples[[length(all_dropped_samples) + 1]] <- data.frame(
745+
plateID = plate,
746+
matrix = matrix_name,
747+
sampleName = dropped,
748+
stringsAsFactors = FALSE
749+
)
750+
}
751+
}
752+
}
753+
}
754+
755+
# Emit warning if any samples were dropped
756+
if (length(all_dropped_samples) > 0) {
757+
dropped_samples_df <- do.call(rbind, all_dropped_samples)
758+
759+
# Create summary warning message
760+
warning_summary <- dropped_samples_df %>%
761+
dplyr::group_by(plateID, matrix) %>%
762+
dplyr::summarize(
763+
n = dplyr::n(),
764+
samples = paste(sampleName, collapse = ", "),
765+
.groups = "drop"
766+
)
767+
768+
warning_messages <- sapply(seq_len(nrow(warning_summary)), function(i) {
769+
row <- warning_summary[i, ]
770+
sprintf("Plate: '%s', Data matrix: '%s': %d sample(s) dropped (%s)",
771+
row$plateID, row$matrix, row$n, row$samples)
772+
})
773+
774+
# Identify unique samples dropped from Data_AQ matrices (affects quantifiability)
775+
aq_dropped <- dropped_samples_df %>%
776+
dplyr::filter(grepl("^Data_AQ", matrix)) %>%
777+
dplyr::pull(sampleName) %>%
778+
unique()
779+
780+
quant_message <- ""
781+
if (length(aq_dropped) > 0) {
782+
quant_message <- sprintf("\nSamples not included in quantifiability calculation: %s",
783+
paste(aq_dropped, collapse = ", "))
784+
}
785+
786+
warning(paste0("Samples with all NaN or NA values were removed:\n",
787+
paste(" -", warning_messages, collapse = "\n"),
788+
quant_message),
789+
call. = FALSE)
790+
}
791+
689792
# Extract control sample names and SampleNames from merged samples dataframe
690793
IPC_samples <- samples$sampleName[samples$sampleType == 'IPC']
691794
SC_samples <- samples$sampleName[samples$sampleType == 'SC']
@@ -728,7 +831,7 @@ mergeNULISAseq <- function(dataList, fileNameList, sample_group_covar = "SAMPLE_
728831
}
729832
# Add dataMatrix and unit
730833
return_list <- c(return_list, dataMatrix, unit = unit)
731-
834+
732835
# return the output
733836
return(return_list)
734837
}
@@ -843,9 +946,7 @@ mergeNULISAseq <- function(dataList, fileNameList, sample_group_covar = "SAMPLE_
843946
#' \item{\code{inconsistent_targets}: Placeholder for targets inconsistent across plates/runs (NULL if none)}
844947
#' \item{\code{detectability}: Data frame of detectability by target and sample matrix}
845948
#' \item{\code{quantifiability}: Data frame of quantifiability by target and sample matrix}
846-
#' \item{\code{Data_raw}, \code{Data_rawlog2}: Raw counts and log2-transformed counts (matrix, targets × samples)}
847-
#' \item{\code{Data_IC}, \code{Data_IClog2}: Internal control–normalized data (linear and log2)}
848-
#' \item{\code{Data_Reverse}, \code{Data_Reverselog2}: Reverse-transformed IC-IPC normalized data (linear and log2)}
949+
#' \item{\code{Data_raw}: Raw counts (matrix, targets × samples)}
849950
#' \item{\code{Data_AQ_aM}, \code{Data_AQlog2_aM}: Absolute quantitation in attomolar units (linear and log2),
850951
#' if AQ data available}
851952
#' \item{\code{Data_AQ_pgmL}, \code{Data_AQlog2_pgmL}: Absolute quantitation in pg/mL units (linear and log2),
@@ -2176,7 +2277,7 @@ filter_target_qc_by_mode <- function(data, AQ = FALSE) {
21762277
#' \item{\code{targets} - Target metadata data frame}
21772278
#' \item{\code{samples} - Sample metadata data frame}
21782279
#' \item{\code{ExecutionDetails} - Execution details list}
2179-
#' \item{\code{Data_Reverselog2} or \code{Data_NPQ} - NPQ data matrix for RQ}
2280+
#' \item{\code{Data_IClog2} or \code{Data_NPQ} - NPQ data matrix for RQ}
21802281
#' \item{\code{Data_raw} - Raw count matrix for RQ}
21812282
#' \item{\code{Data_AQ} or \code{Data_AQ_aM} - Absolute quantification matrix in aM (optional)}
21822283
#' \item{\code{Data_AQ_pgmL} - Absolute quantification matrix in pg/mL (optional)}
@@ -2258,12 +2359,12 @@ format_wide_to_long <- function(merged, AQ = FALSE, exclude_sample_cols = "plate
22582359

22592360
} else {
22602361
# Check which NPQ data column is available
2261-
NPQ_data_used <- if("Data_Reverselog2" %in% names(merged)) {
2262-
"Data_Reverselog2"
2362+
NPQ_data_used <- if("Data_IClog2" %in% names(merged)) {
2363+
"Data_IClog2"
22632364
} else if("Data_NPQ" %in% names(merged)) {
2264-
"Data_NPQ"
2365+
"Data_NPQ"
22652366
} else {
2266-
stop("Neither 'Data_Reverselog2' nor 'Data_NPQ' found in the merged data")
2367+
stop("Neither 'Data_IClog2' nor 'Data_NPQ' found in the merged data")
22672368
}
22682369

22692370
data_long <- convert_to_long(data_matrix = merged[[NPQ_data_used]], data_col ="NPQ")

R/quantifiability.R

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ quantifiability <- function(runs,
143143
DR$LLOQ[DR$LOD_aM > DR$LLOQ & !is.na(DR$LOD_aM) & !is.na(DR$LLOQ)] <- DR$LOD_aM[DR$LOD_aM > DR$LLOQ & !is.na(DR$LOD_aM) & !is.na(DR$LLOQ)]
144144

145145
# calculate overall quantifiability
146-
AQ_quant <- x$AQ$Data_AQ_aM[,x$SampleNames, drop=FALSE]
146+
intersect_samples <- intersect(colnames(x$AQ$Data_AQ_aM), x$SampleNames)
147+
AQ_quant <- x$AQ$Data_AQ_aM[,intersect_samples, drop=FALSE]
147148
AQ_quant <- merge(AQ_quant, DR, by.x='row.names', by.y='targetName')
148149
rownames(AQ_quant) <- AQ_quant[,1]
149150
AQ_quant <- AQ_quant[,2:ncol(AQ_quant)]
@@ -156,15 +157,17 @@ quantifiability <- function(runs,
156157
})
157158

158159
AQ_quant_output_columns <- c('overall')
159-
n_samples <- c(overall=length(x$SampleNames))
160+
n_samples <- c(overall=length(intersect_samples))
160161

161162
# calculate subgroup quantifiability
162163
if(!is.null(sampleGroupCovar)){
163164
if(sampleGroupCovar %in% colnames(x$samples)){
164165
subgroup_names <- unique(x$samples[x$samples$sampleType=='Sample', sampleGroupCovar])
165166

166167
for(i in 1:length(subgroup_names)){
167-
subgroup_samples <- x$samples$sampleName[x$samples[,sampleGroupCovar]==subgroup_names[i]]
168+
# Filter samples to those in intersect_samples AND belonging to this subgroup
169+
samples_in_subgroup <- x$samples$sampleName[x$samples[,sampleGroupCovar] == subgroup_names[i]]
170+
subgroup_samples <- intersect(samples_in_subgroup, intersect_samples)
168171
subgroup_sample_data <- AQ_quant[,subgroup_samples, drop=FALSE]
169172
AQ_quant[,as.character(subgroup_names[i])] <- NA
170173
AQ_quant_output_columns <- c(AQ_quant_output_columns, as.character(subgroup_names[i]))

0 commit comments

Comments
 (0)