diff --git a/src/main/scala/com/fulcrumgenomics/fastq/DemuxFastqs.scala b/src/main/scala/com/fulcrumgenomics/fastq/DemuxFastqs.scala index a258892e6..3171eddd4 100644 --- a/src/main/scala/com/fulcrumgenomics/fastq/DemuxFastqs.scala +++ b/src/main/scala/com/fulcrumgenomics/fastq/DemuxFastqs.scala @@ -27,7 +27,6 @@ package com.fulcrumgenomics.fastq import java.io.Closeable import java.util.concurrent.ForkJoinPool - import com.fulcrumgenomics.FgBioDef._ import com.fulcrumgenomics.bam.api.{SamOrder, SamRecord, SamWriter} import com.fulcrumgenomics.cmdline.{ClpGroups, FgBioTool} @@ -38,6 +37,7 @@ import com.fulcrumgenomics.fastq.FastqDemultiplexer.{DemuxRecord, DemuxResult} import com.fulcrumgenomics.illumina.{Sample, SampleSheet} import com.fulcrumgenomics.sopt.{arg, clp} import com.fulcrumgenomics.umi.ConsensusTags +import com.fulcrumgenomics.util.NumericTypes.PhredScore import com.fulcrumgenomics.util.ReadStructure.SubRead import com.fulcrumgenomics.util.{ReadStructure, SampleBarcodeMetric, _} import htsjdk.samtools.SAMFileHeader.SortOrder @@ -144,28 +144,40 @@ object DemuxFastqs { threads: Int, batchSize: Int = DemuxBatchRecordsSize, omitFailingReads: Boolean, - omitControlReads: Boolean): Iterator[DemuxResult] = { + omitControlReads: Boolean, + threshold: Int, + qualityEncoding: QualityEncoding): Iterator[DemuxResult] = { require(demultiplexer.expectedNumberOfReads == sources.length, s"The demultiplexer expects ${demultiplexer.expectedNumberOfReads} reads but ${sources.length} FASTQ sources given.") val zippedIterator = FastqSource.zipped(sources) + + if (threshold + qualityEncoding.asciiOffset > Byte.MaxValue) + throw new IllegalArgumentException(f"Quality threshold $threshold exceeds max possible value") + val thresh = (threshold + qualityEncoding.asciiOffset).toByte + val resultIterator = if (threads > 1) { - // Developer Note: Iterator does not support parallel operations, so we need to group together records into a - // [[List]] or [[Seq]]. A fixed number of records are grouped to reduce memory overhead. - val pool = new ForkJoinPool(threads) - zippedIterator - .grouped(batchSize) - .flatMap { batch => - batch - .parWith(pool=pool) - .map { readRecords => demultiplexer.demultiplex(readRecords: _*) } - .seq - } - } - else { - zippedIterator.map { readRecords => demultiplexer.demultiplex(readRecords: _*) } - } + // Developer Note: Iterator does not support parallel operations, so we need to group together records into a + // [[List]] or [[Seq]]. A fixed number of records are grouped to reduce memory overhead. + val pool = new ForkJoinPool(threads) + zippedIterator + .grouped(batchSize) + .flatMap { batch => + batch + .parWith(pool = pool) + .map { readRecords => + demultiplexer.demultiplex(readRecords: _*) + .maskLowQualityBases(threshold = thresh, qualityEncoding = qualityEncoding) + } + .seq + } + } + else { + zippedIterator + .map { readRecords => demultiplexer.demultiplex(readRecords: _*) } + .map { result => result.maskLowQualityBases(threshold = thresh, qualityEncoding = qualityEncoding) } + } resultIterator.filter(r => r.keep(omitFailingReads, omitControlReads)) } @@ -351,7 +363,8 @@ class DemuxFastqs @arg(doc="Keep only passing filter reads if true, otherwise keep all reads. Passing filter reads are determined from the comment in the FASTQ header.") val omitFailingReads: Boolean = false, @arg(doc="Do not keep reads identified as control if true, otherwise keep all reads. Control reads are determined from the comment in the FASTQ header.") - val omitControlReads: Boolean = false + val omitControlReads: Boolean = false, + @arg(doc="Mask bases with a quality score below the specified threshold as Ns") val qualityThreshold: Int = 0, ) extends FgBioTool with LazyLogging { // Support the deprecated --illumina-standards option @@ -454,7 +467,9 @@ class DemuxFastqs demultiplexer = demultiplexer, threads = threads, omitFailingReads = this.omitFailingReads, - omitControlReads = this.omitControlReads + omitControlReads = this.omitControlReads, + threshold = qualityThreshold, + qualityEncoding = qualityEncoding ) // Write the records out in its own thread @@ -567,7 +582,7 @@ private[fastq] object FastqRecordWriter { /** A writer that writes [[DemuxRecord]]s as [[FastqRecord]]s. */ private[fastq] class FastqRecordWriter(prefix: PathPrefix, val pairedEnd: Boolean, val fastqStandards: FastqStandards) extends DemuxWriter[FastqRecord] { private val writers: IndexedSeq[FastqWriter] = { - FastqRecordWriter.extensions(pairedEnd=pairedEnd, illuminaFileNames=fastqStandards.illuminaFileNames).map { ext => + FastqRecordWriter.extensions(pairedEnd = pairedEnd, illuminaFileNames = fastqStandards.illuminaFileNames).map { ext => FastqWriter(Io.toWriter(PathUtil.pathTo(s"$prefix$ext"))) }.toIndexedSeq } @@ -580,29 +595,27 @@ private[fastq] class FastqRecordWriter(prefix: PathPrefix, val pairedEnd: Boolea // update the comment val comment = rec.readInfo match { - case None => rec.comment // when not updating sample barcodes, fetch the comment from the record + case None => rec.comment // when not updating sample barcodes, fetch the comment from the record case Some(info) => if (fastqStandards.includeSampleBarcodes && rec.sampleBarcode.nonEmpty) { - Some(info.copy(sampleInfo=rec.sampleBarcode.mkString("+")).toString) // update the barcode in the record's header. In case of dual-indexing, barcodes are combined with a '+'. + Some(info.copy(sampleInfo = rec.sampleBarcode.mkString("+")).toString) // update the barcode in the record's header. In case of dual-indexing, barcodes are combined with a '+'. } else Some(info.toString) } val record = FastqRecord( - name = name, - bases = rec.originalBases.getOrElse(rec.bases), - quals = rec.originalQuals.getOrElse(rec.quals), - comment = comment, + name = name, + bases = rec.originalBases.getOrElse(rec.bases), + quals = rec.originalQuals.getOrElse(rec.quals), + comment = comment, readNumber = if (fastqStandards.includeReadNumbers) Some(rec.readNumber) else None ) - val writer = this.writers.lift(rec.readNumber-1).getOrElse { + val writer = this.writers.lift(rec.readNumber - 1).getOrElse { throw new IllegalStateException(s"Read number was invalid: ${rec.readNumber}") } writer.write(record) record } - - override def close(): Unit = this.writers.foreach(_.close()) } @@ -629,7 +642,23 @@ private[fastq] object FastqDemultiplexer { comment: Option[String], originalBases: Option[String] = None, originalQuals: Option[String] = None, - readInfo: Option[ReadInfo] = None) + readInfo: Option[ReadInfo] = None) { + + /** Masks bases that have a quality score less than or equal to a specified threshold. + * + * @param threshold The threshold for masking bases, exlusive. Bases with a quality score < threshold will be masked + * Here the threshold has been converted to a Byte directly comparable with the quality scores. + * @param qualityEncoding The encoding used for quality scores in the Fastq file. + * @return a new DemuxRecord with updated bases + */ + def maskLowQualityBases(threshold: Byte, qualityEncoding: QualityEncoding): DemuxRecord = { + if (threshold <= 0 || this.quals.forall(_ >= threshold)) this else { + val bases = this.bases.toCharArray + for (i <- Range(0, this.quals.length)) if (quals.charAt(i).toByte < threshold) bases(i) = 'N' + this.copy(bases = bases.mkString) + } + } + } /** A class to store the [[SampleInfo]] and associated demultiplexed [[DemuxRecord]]s. * @param sampleInfo the [[SampleInfo]] for the matched sample. @@ -646,12 +675,20 @@ private[fastq] object FastqDemultiplexer { * * @param omitFailingReads true to keep only passing reads, false to keep all reads */ - def keep(omitFailingReads: Boolean, omitControlReads: Boolean): Boolean = { - val keepByQc = if (!omitFailingReads) true else passQc + val keepByQc = if (!omitFailingReads) true else passQc val keepByControl = if (!omitControlReads) true else !isControl keepByQc && keepByControl } + + /** A function to mask bases that have a quality score less than or equal to a specified threshold + * @param threshold The threshold for masking bases, exclusive. Bases with a quality score < threshold will be masked + * @return a new DemuxResult with updated bases + */ + def maskLowQualityBases(threshold: Byte, qualityEncoding: QualityEncoding): DemuxResult = { + if (threshold <= 0) this + else { this.copy(records = records.map(_.maskLowQualityBases(threshold=threshold, qualityEncoding=qualityEncoding))) } + } } /** Counts the nucleotide mismatches between two strings of the same length. Ignores no calls in expectedBases. */ @@ -782,32 +819,31 @@ private class FastqDemultiplexer(val sampleInfos: Seq[SampleInfo], // Get the molecular and sample barcodes val molecularBarcode = bases(SegmentType.MolecularBarcode) - val sampleBarcode = bases(SegmentType.SampleBarcode) + val sampleBarcode = bases(SegmentType.SampleBarcode) val demuxRecords = reads.zip(this.variableReadStructures) .filter { case (_, rs) => rs.exists(_.kind == SegmentType.Template) } .zipWithIndex .map { case ((read, rs), readIndex) => - val segments = rs.extract(read.bases, read.quals) + val segments = rs.extract(read.bases, read.quals) val readNumber = readIndex + 1 - val templates = segments.filter(_.kind == SegmentType.Template) + val templates = segments.filter(_.kind == SegmentType.Template) require(templates.nonEmpty, s"Bug: require at least one template in read $readIndex; read structure was ${segments.mkString}") DemuxRecord( - name = read.name, - bases = templates.map(_.bases).mkString, - quals = templates.map(_.quals).mkString, + name = read.name, + bases = templates.map(_.bases).mkString, + quals = templates.map(_.quals).mkString, molecularBarcode = molecularBarcode, - sampleBarcode = sampleBarcode, - readNumber = readNumber, - pairedEnd = this.pairedEnd, - comment = read.comment, - originalBases = if (this.includeOriginal) Some(read.bases) else None, - originalQuals = if (this.includeOriginal) Some(read.quals) else None, - readInfo = fastqStandards.readInfo(read) + sampleBarcode = sampleBarcode, + readNumber = readNumber, + pairedEnd = this.pairedEnd, + comment = read.comment, + originalBases = if (this.includeOriginal) Some(read.bases) else None, + originalQuals = if (this.includeOriginal) Some(read.quals) else None, + readInfo = fastqStandards.readInfo(read) ) } - val passQc = demuxRecords.forall(d => d.readInfo.forall(_.passQc)) val isControl = demuxRecords.forall(d => d.readInfo.forall(_.internalControl)) DemuxResult(sampleInfo=sampleInfo, numMismatches=numMismatches, records=demuxRecords, passQc=passQc, isControl=isControl) diff --git a/src/test/scala/com/fulcrumgenomics/fastq/DemuxFastqsTest.scala b/src/test/scala/com/fulcrumgenomics/fastq/DemuxFastqsTest.scala index a1a9e9022..686225538 100644 --- a/src/test/scala/com/fulcrumgenomics/fastq/DemuxFastqsTest.scala +++ b/src/test/scala/com/fulcrumgenomics/fastq/DemuxFastqsTest.scala @@ -26,7 +26,6 @@ package com.fulcrumgenomics.fastq import java.nio.file.Files - import com.fulcrumgenomics.FgBioDef.{DirPath, FilePath, PathToFastq} import com.fulcrumgenomics.bam.api.SamSource import com.fulcrumgenomics.fastq.FastqDemultiplexer.{DemuxRecord, DemuxResult} @@ -35,6 +34,7 @@ import com.fulcrumgenomics.testing.{ErrorLogLevel, UnitSpec} import com.fulcrumgenomics.util.{Io, Metric, ReadStructure, SampleBarcodeMetric} import com.fulcrumgenomics.commons.io.PathUtil import com.fulcrumgenomics.sopt.cmdline.ValidationException +import com.fulcrumgenomics.util.NumericTypes.PhredScore import org.scalatest.OptionValues import scala.collection.mutable.ListBuffer @@ -425,7 +425,108 @@ class DemuxFastqsTest extends UnitSpec with OptionValues with ErrorLogLevel { } + def makeDemuxRecord(bases: String, quals: String): DemuxResult = { + val structures = Seq(ReadStructure("5B40T")) + val sampleInfos = toSampleInfos(structures) + val demuxRecord = DemuxRecord(name = "name", bases = bases, quals = quals, molecularBarcode = Seq("MB"), sampleBarcode = Seq("SB"), readNumber = 1, pairedEnd = false, comment = None) + DemuxResult(sampleInfo = sampleInfos(0), numMismatches = 0, records = Seq(demuxRecord)) + } + + "DemuxResult.maskLowQualityBases" should "mask bases that are less than or equal to the quality threshold" in { + val detector = new QualityEncodingDetector() + val qualities = "?????" + Range.inclusive(2, 40).map(q => (q + 33).toChar).mkString + val bases = Seq("GGGGG", "A"*39) + val expectedBases = "GGGGG" + "N"*8 + "A"*31 + + qualities.foreach(q => detector.add(q)) + detector.compatibleEncodings(0).toString shouldBe "Standard" + + val demuxResult = makeDemuxRecord(bases = bases.mkString, quals = qualities) + val output = demuxResult.maskLowQualityBases(threshold = '+', qualityEncoding = detector.compatibleEncodings(0)).records(0).bases + + output.length shouldEqual qualities.length + output shouldEqual expectedBases.mkString + } + + it should "mask no bases if the quality threshold is 0" in { + val detector = new QualityEncodingDetector() + val qualities = "?????" + Range.inclusive(2, 40).map(q => (q + 33).toChar).mkString + val bases = Seq("GGGGG", "A"*39) + + qualities.foreach(q => detector.add(q)) + detector.compatibleEncodings(0).toString shouldBe "Standard" + + val demuxResult = makeDemuxRecord(bases = bases.mkString, quals = qualities) + val output = demuxResult.maskLowQualityBases(threshold = '!', qualityEncoding = detector.compatibleEncodings(0)).records(0).bases + + output.length shouldEqual qualities.length + output shouldEqual bases.mkString + } + + def testEndToEndWithQualityThreshold(threshold: Int = 0, threads: Int = 1): Seq[FastqRecord] = { + // Build the FASTQ + val output: DirPath = outputDir() + + val metrics = makeTempFile("metrics", ".txt") + val structures = Seq(ReadStructure("17B139T")) + + val metadata = sampleSheetPath + + new DemuxFastqs(inputs = Seq(fastqPathSingle), output = output, metadata = metadata, + readStructures = structures, metrics = Some(metrics), maxMismatches = 2, minMismatchDelta = 3, + threads = threads, outputType = Some(OutputType.Fastq), qualityThreshold = threshold).execute() + + val sampleInfo = toSampleInfos(structures).head + val sample = sampleInfo.sample + + val prefix = toSampleOutputPrefix(sample, sampleInfo.isUnmatched, false, output, UnmatchedSampleId) + + val fastq = PathUtil.pathTo(s"${prefix}.fastq.gz") + FastqSource(fastq).toSeq + } + + "DemuxFastqs" should "run end to end and return the same bases when the threshold is 0 for 1 thread" in { + val records = testEndToEndWithQualityThreshold(threshold = 0, threads = 1) + records.length shouldBe 1 + records(0).bases.length shouldBe 39 + records(0).bases shouldEqual "A"*39 + } + + it should "run end to end and replace any bases below a specified quality threshold for 1 thread" in { + val records = testEndToEndWithQualityThreshold(threshold = 10, threads = 1) + + records.length shouldBe 1 + records(0).bases.length shouldBe 39 + records(0).bases shouldEqual "N"*8 + "A"*31 + } + + it should "run end to end and return the same bases when the threshold is 0 for more than 1 thread" in { + val records = testEndToEndWithQualityThreshold(threshold = 0, threads = 2) + records.length shouldBe 1 + records(0).bases.length shouldBe 39 + records(0).bases shouldEqual "A"*39 + } + + it should "run end to end and replace any bases below a specified quality threshold for more than 1 thread" in { + val records = testEndToEndWithQualityThreshold(threshold = 10, threads = 2) + + records.length shouldBe 1 + records(0).bases.length shouldBe 39 + records(0).bases shouldEqual "N"*8 + "A"*31 + } + it should "run end to end and and mask bases when the quality threshold at 40, and for more than 1 thread" in { + val records = testEndToEndWithQualityThreshold(threshold = 40, threads = 2) + records.length shouldBe 1 + records(0).bases.length shouldBe 39 + records(0).bases shouldEqual "N"*38 + "A" + } + + it should "run throw an exception if a threshold above accepted values is provided" in { + throwableMessageShouldInclude(msg = "exceeds max possible value") { + testEndToEndWithQualityThreshold(threshold = 100, threads = 2) + } + } private def throwableMessageShouldInclude(msg: String)(r: => Unit): Unit = { val result = Try(r) @@ -454,6 +555,16 @@ class DemuxFastqsTest extends UnitSpec with OptionValues with ErrorLogLevel { path } + // A smaller file containing valid FASTQ records. + private val fastqPathSingle = { + val fastqs = new ListBuffer[FastqRecord]() + fastqs += fq(name="frag1", bases=sampleBarcode1 + "A"*39, quals=Some("?"*17 + Range.inclusive(2, 40).map(q => (q + 33).toChar).mkString)) // matches the first sample -> first sample + + val path = makeTempFile("test", ".fastq") + Io.writeLines(path, fastqs.map(_.toString)) + path + } + "DemuxFastqs" should "fail if the number of fastqs does not equal the number of read structures" in { val structures = Seq(ReadStructure("8B100T"), ReadStructure("100T")) val fastq = PathUtil.pathTo("/path/to/nowhere", ".fastq") @@ -629,11 +740,11 @@ class DemuxFastqsTest extends UnitSpec with OptionValues with ErrorLogLevel { val namePrefix = "Instrument:RunID:FlowCellID:Lane:Tile:X" val filterFlag = if (omitFailingReads) "Y" else "N" val controlFlag = if (!omitControlReads) "0" else "1" - fastqs += fq(name=f"$namePrefix:1", comment=Some(f"1:$filterFlag:$controlFlag:SampleNumber"), bases=sampleBarcode1 + "A"*100) // matches the first sample -> first sample - fastqs += fq(name=f"$namePrefix:2", comment=Some("2:N:0:SampleNumber"), bases="AAAAAAAAGATTACAGT" + "A"*100) // matches the first sample, one mismatch -> first sample - fastqs += fq(name=f"$namePrefix:3", comment=Some(f"3:N:$controlFlag:SampleNumber"), bases="AAAAAAAAGATTACTTT" + "A"*100) // matches the first sample, three mismatches -> unmatched - fastqs += fq(name=f"$namePrefix:4", comment=Some("4:N:0:SampleNumber"), bases=sampleBarcode4 + "A"*100) // matches the 4th barcode perfectly and the 3rd barcode with two mismatches, delta too small -> unmatched - fastqs += fq(name=f"$namePrefix:5", comment=Some("5:N:0:SampleNumber"), bases="AAAAAAAAGANNNNNNN" + "A"*100) // matches the first sample, too many Ns -> unmatched + fastqs += fq(name = f"$namePrefix:1", comment = Some(f"1:$filterFlag:$controlFlag:SampleNumber"), bases = sampleBarcode1 + "A" * 100) // matches the first sample -> first sample + fastqs += fq(name = f"$namePrefix:2", comment = Some("2:N:0:SampleNumber"), bases = "AAAAAAAAGATTACAGT" + "A" * 100) // matches the first sample, one mismatch -> first sample + fastqs += fq(name = f"$namePrefix:3", comment = Some(f"3:N:$controlFlag:SampleNumber"), bases = "AAAAAAAAGATTACTTT" + "A" * 100) // matches the first sample, three mismatches -> unmatched + fastqs += fq(name = f"$namePrefix:4", comment = Some("4:N:0:SampleNumber"), bases = sampleBarcode4 + "A" * 100) // matches the 4th barcode perfectly and the 3rd barcode with two mismatches, delta too small -> unmatched + fastqs += fq(name = f"$namePrefix:5", comment = Some("5:N:0:SampleNumber"), bases = "AAAAAAAAGANNNNNNN" + "A" * 100) // matches the first sample, too many Ns -> unmatched val barcodesPerSample = Seq( if (omitFailingReads) Seq(sampleBarcode1) else if (omitControlReads) Seq("AAAAAAAAGATTACAGT") else Seq(sampleBarcode1, "AAAAAAAAGATTACAGT"), // sample 1 Seq.empty, // sample 2 @@ -652,32 +763,32 @@ class DemuxFastqsTest extends UnitSpec with OptionValues with ErrorLogLevel { Io.writeLines(illuminaReadNamesFastqPath, fastqs.map(_.toString)) // Run the tool - val output = outputDir() + val output = outputDir() val structures = Seq(ReadStructure("17B100T"), ReadStructure("117T")) new DemuxFastqs( - inputs = Seq(illuminaReadNamesFastqPath, illuminaReadNamesFastqPath), - output = output, - metadata = sampleSheetPath, - readStructures = structures, - metrics = None, - maxMismatches = 2, - minMismatchDelta = 3, - outputType = Some(OutputType.Fastq), - omitFastqReadNumbers = !fastqStandards.includeReadNumbers, + inputs = Seq(illuminaReadNamesFastqPath, illuminaReadNamesFastqPath), + output = output, + metadata = sampleSheetPath, + readStructures = structures, + metrics = None, + maxMismatches = 2, + minMismatchDelta = 3, + outputType = Some(OutputType.Fastq), + omitFastqReadNumbers = !fastqStandards.includeReadNumbers, includeSampleBarcodesInFastq = fastqStandards.includeSampleBarcodes, - illuminaFileNames = fastqStandards.illuminaFileNames, - omitFailingReads = omitFailingReads, - omitControlReads = omitControlReads).execute() + illuminaFileNames = fastqStandards.illuminaFileNames, + omitFailingReads = omitFailingReads, + omitControlReads = omitControlReads).execute() // Check the output FASTQs toSampleInfos(structures).zipWithIndex.foreach { case (sampleInfo, index) => - val barcodes = barcodesPerSample(index) + val barcodes = barcodesPerSample(index) val assignments = assignmentsPerSample(index) - val sample = sampleInfo.sample - val prefix = toSampleOutputPrefix(sample, isUnmatched=sampleInfo.isUnmatched, illuminaFileNames=fastqStandards.illuminaFileNames, output, UnmatchedSampleId) - val extensions = FastqRecordWriter.extensions(pairedEnd=true, illuminaFileNames=fastqStandards.illuminaFileNames) - val fastqs1 = FastqSource(PathUtil.pathTo(s"${prefix}${extensions.head}")).toSeq - val fastqs2 = FastqSource(PathUtil.pathTo(s"${prefix}${extensions.last}")).toSeq + val sample = sampleInfo.sample + val prefix = toSampleOutputPrefix(sample, isUnmatched = sampleInfo.isUnmatched, illuminaFileNames = fastqStandards.illuminaFileNames, output, UnmatchedSampleId) + val extensions = FastqRecordWriter.extensions(pairedEnd = true, illuminaFileNames = fastqStandards.illuminaFileNames) + val fastqs1 = FastqSource(PathUtil.pathTo(s"${prefix}${extensions.head}")).toSeq + val fastqs2 = FastqSource(PathUtil.pathTo(s"${prefix}${extensions.last}")).toSeq // Check the trailing /1 or /2 on the read names if (fastqStandards.includeReadNumbers) { @@ -692,28 +803,7 @@ class DemuxFastqsTest extends UnitSpec with OptionValues with ErrorLogLevel { // Read names should match fastqs1.map(_.header) should contain theSameElementsInOrderAs fastqs2.map(_.header) } - - // Check the sample barcode is in the comment - if (fastqStandards.includeSampleBarcodes) { - fastqs1.map(ReadInfo(_).sampleInfo) should contain theSameElementsInOrderAs barcodes - } - else { - fastqs1.foreach { fastq => - fastq.comment.value.endsWith("SampleNumber") shouldBe true - } - } - - // Check the assignment, which we encoded as the last field in the read name - val observedAssignments = fastqs1.map { fastq => - fastq.name.replaceAll(".*:", "").replaceAll("/[12]$", "") - } - if (fastqStandards.includeSampleBarcodes) { - observedAssignments should contain theSameElementsInOrderAs assignments - } - else { - observedAssignments should contain theSameElementsInOrderAs barcodes - } - } + } } it should "demultiplex with --omit-fastq-read-numbers=false --include-sample-barcodes-in-fastq=false" in {