From 8d0c321e8ef94a76aa681be3349de252b7051096 Mon Sep 17 00:00:00 2001 From: David Priest Date: Fri, 11 Sep 2026 20:07:35 +0900 Subject: [PATCH] Spectral detection needs more than one raw detector, and the keep rule matches the detection's case A conventional file with one unlabelled fluorescence channel was classified as spectral-unmixed, which dropped its -H/-W channels and changed every gate's channel identity between files of a panel that differed only in that label. The rule now needs more than one raw detector, as GateLab's channels.ts does. The keep rule tested the "-A" suffix case-sensitively while the detection did not, so a marker written "-a" made the file unmixed and was then dropped. Co-Authored-By: Claude Fable 5.1 --- inst/app/R/fcs_import.R | 18 ++++++++---- tests/testthat/test-channel-identities.R | 36 ++++++++++++++++++++---- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/inst/app/R/fcs_import.R b/inst/app/R/fcs_import.R index 3c6d7b0..81876f0 100644 --- a/inst/app/R/fcs_import.R +++ b/inst/app/R/fcs_import.R @@ -328,16 +328,20 @@ filter_flow_channels <- function(ff) { # rather than the detector — $PnN "FL1-A" with $PnS "FITC-A" — satisfies it on every # fluorescence channel while having no detectors at all, and filtering it drops the # height partners and any spelled-out width. Beckman CytoFLEX lost 5 of 14 channels - # this way and Xitogen XTG-1600 lost 14 of 32. So also require at least one channel - # the filter would actually drop: not scatter, not LightLoss/Autofluorescence/ - # Extinction, not QC/timing, and carrying no marker of its own. + # this way and Xitogen XTG-1600 lost 14 of 32. So also require channels the filter + # would actually drop -- not scatter, not LightLoss/Autofluorescence/Extinction, not + # QC/timing, and carrying no marker of its own -- and more than one of them: a + # conventional file with a single unlabelled fluorescence channel (an unstained or + # spare detector) was taken for a spectral one, which dropped its height and width + # channels and changed every gate's channel identity between files of one panel. A + # spectral file carries dozens. GateLab's channels.ts applies the same rule. has_marker <- !is.na(pns) & nchar(trimws(pns)) > 0 & pns != pnn is_raw_detector <- !has_marker & !grepl("^(FSC|SSC)", pnn, ignore.case = TRUE) & !grepl("^(LightLoss|Autofluorescence|Extinction)", pnn, ignore.case = TRUE) & !grepl("^(Time|Event_length|Cell_length)$", pnn, ignore.case = TRUE) - if (sum(is_unmixed) < 2 || !any(is_raw_detector)) { + if (sum(is_unmixed) < 2 || sum(is_raw_detector) < 2) { display_name <- ifelse(is.na(pns) | nchar(trimws(pns)) == 0, pnn, pns) message(" Conventional flow: keeping all ", length(pnn), " channels") return(list(ff = ff, display_names = display_name, @@ -382,9 +386,11 @@ filter_flow_channels <- function(ff) { next } - # Unmixed fluorophore channels: PnS != PnN AND PnS ends with "-A" + # Unmixed fluorophore channels: PnS != PnN AND PnS ends with "-A". Case-insensitive, as + # the detection above is: a marker written "-a" counted towards making the file unmixed + # and then fell through to the drop branch here, losing the channel. if (!is.na(desc) && nchar(trimws(desc)) > 0 && - desc != ch && grepl("-A$", desc)) { + desc != ch && grepl("-A$", desc, ignore.case = TRUE)) { keep_idx[i] <- TRUE display_name[i] <- paste0(desc, " (", ch, ")") pnr_out[i] <- pnr[i] diff --git a/tests/testthat/test-channel-identities.R b/tests/testthat/test-channel-identities.R index aa2d245..210731f 100644 --- a/tests/testthat/test-channel-identities.R +++ b/tests/testthat/test-channel-identities.R @@ -59,13 +59,14 @@ test_that("conjugate names ending -A do not make a file spectral-unmixed", { unlink(path) }) -test_that("a raw detector alongside unmixed markers still triggers filtering", { +test_that("raw detectors alongside unmixed markers still trigger filtering", { skip_if_not_installed("flowCore") - # Same file plus one raw spectral detector (no $PnS of its own). That is what an - # unmixed export looks like, so the detector is dropped and markers are renamed. - chans <- c("FSC-A", "B1-A", "V500-A", "PE-A") - descs <- c("FSC-A", NA, "CD4-A", "CD25-A") + # Same file plus raw spectral detectors (no $PnS of their own). That is what an unmixed + # export looks like, so the detectors are dropped and markers are renamed. A marker written + # "-a" is an unmixed marker too, in the keep rule as in the detection. + chans <- c("FSC-A", "B1-A", "B2-A", "V500-A", "PE-A") + descs <- c("FSC-A", NA, NA, "cd4-a", "CD25-A") values <- matrix(seq_len(3 * length(chans)), nrow = 3, dimnames = list(NULL, chans)) frame <- flowCore::flowFrame(values) @@ -76,7 +77,30 @@ test_that("a raw detector alongside unmixed markers still triggers filtering", { sce <- import_fcs_files(path, instrument_mode = "flow") expect_false("B1-A" %in% rownames(sce)) - expect_identical(rownames(sce), c("FSC-A", "CD4-A (V500-A)", "CD25-A (PE-A)")) + expect_false("B2-A" %in% rownames(sce)) + expect_identical(rownames(sce), c("FSC-A", "cd4-a (V500-A)", "CD25-A (PE-A)")) + + unlink(path) +}) + +test_that("one unlabelled fluorescence channel does not make a conventional file spectral", { + skip_if_not_installed("flowCore") + + # An unstained or spare channel on a conventional analyser: one channel without $PnS. It + # used to count as the raw detector that makes a file spectral-unmixed, which dropped every + # -H channel and changed the gate channel identities between files of one panel. + chans <- c("FSC-A", "FL1-H", "FL1-A", "FL2-H", "FL2-A", "FL3-A") + descs <- c("FSC-A", "FITC-H", "FITC-A", "PE-H", "PE-A", NA) + values <- matrix(seq_len(3 * length(chans)), nrow = 3, + dimnames = list(NULL, chans)) + frame <- flowCore::flowFrame(values) + flowCore::pData(flowCore::parameters(frame))$desc <- descs + path <- tempfile(fileext = ".fcs") + flowCore::write.FCS(frame, path) + + sce <- import_fcs_files(path, instrument_mode = "flow") + + expect_identical(rownames(sce), c("FSC-A", "FITC-H", "FITC-A", "PE-H", "PE-A", "FL3-A")) unlink(path) })