From 8f89473a77ed3e139656efd71fb2ea7887a9160e Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 13 Oct 2026 09:24:35 +0200 Subject: [PATCH 01/13] ci: add python unit tests and dual-platform nextflow stub test matrix --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e60163..97c0bac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,42 @@ name: 'longvarmeth CI' on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev workflow_dispatch: -# on: -# push: -# branches: -# - main -# - master -# - dev -# pull_request: -# branches: -# - main -# - master - jobs: - test: - name: 'Run pipeline test profile' + pytest: + name: 'Python Unit & Integrity Tests' runs-on: ubuntu-latest + steps: + - name: Check out pipeline code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies & run pytest + run: | + python -m pip install --upgrade pip + pip install pytest + pytest tests/unit_tests/ -v + + pipeline_stub: + name: 'Nextflow Pipeline Stub Tests' + runs-on: ubuntu-latest + needs: pytest + strategy: + matrix: + profile: ['test_ont', 'test_pacbio'] steps: - name: Check out pipeline code uses: actions/checkout@v4 @@ -29,4 +48,5 @@ jobs: - name: Run pipeline dry-run stub test run: | - nextflow run main.nf -profile test -stub + nextflow run main.nf -profile ${{ matrix.profile }} -stub + From 8cd591a426b8aa5bdc68e4843dac0322cb2f72d4 Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 13 Oct 2026 09:27:38 +0200 Subject: [PATCH 02/13] fix(assets): whitelist and track dashboard-template.html in git --- .gitignore | 1 + .../dashboard-template.html | 1894 +++++++++++++++++ 2 files changed, 1895 insertions(+) create mode 100644 assets/methylation_dashboard/dashboard-template.html diff --git a/.gitignore b/.gitignore index d71223e..5ea1f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ work/ results/ results_*/ *.html +!assets/**/*.html *.txt diff --git a/assets/methylation_dashboard/dashboard-template.html b/assets/methylation_dashboard/dashboard-template.html new file mode 100644 index 0000000..f675a75 --- /dev/null +++ b/assets/methylation_dashboard/dashboard-template.html @@ -0,0 +1,1894 @@ + + + + + + + Nallo Report - Clinical Genomics + + + + + + + + + + + + + + + + +
+ +

gms-nallo: Methylation Analysis Dashboard

+
+ gms-nallo v0.12.0 + Genome: GRCh38 +
+
+ +
+
+
+ +
+
+
+
+
Active Sample Context
+
--
+
Select a row to begin
+
+
+
+
+
Sample Status Tally
+
--
+
ASM regions detected
+
+
+
+
+
Distribution
+
+ +
+
+
+
+
+
QC Pass Count
+
0
+
Regions passing all QC
+
+
+
+
+
+
Methylation Status (Click Header to Sort)
+
+ Rows: + + + + + + + + +
+
+
+
+ + +
+
+
+
+ + Cohort Reference Benchmark + + 35%–65% Normative Window + + + Reference: Built-in GoS (288 Controls) + +
+
+ Comparing sample bulk methylation against population reference cohort Median, IQR & 95% Normal Range. +
+
+
+
+ + +
+ +
+ + + +
+ + +
+
+ + +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + +
Syndrome / LocusGenomic IntervalGoS Control LocusGoS Controls (N)GoS MedianGoS IQR (Q1–Q3)95% Normal RangeSample ScoreImprinting Status
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+
Methods and Context
+

+ This report summarizes hg38 methylation loci identified by the gsm-nallo pipeline. + Methylation is phased using HiPhase and visualized using IGV-Web. +

+
+ Required Files +
    +
  • *methbat.csv methbat results
  • +
  • *haplotagged.bam & .bai (Phased Reads)
  • +
  • *combined.bw (Total Methylation Signal)
  • +
  • *hap1.bw / *hap2.bw (Allele-Specific Tracks)
  • +
+
+ + View Methods and Run Details + +
+
+
+
+
Real-Time Client Diagnostics
+
+
+ Available CPUs + -- +
+
+ UI Fluidity (FPS) + -- +
+
+ JS Heap Usage + -- + Current RAM footprint +
+
+ Embedded Data + -- +
+
+
+
+
+
+
+ +
+
Copyright © 2026 Jyotirmoy Das
+
Bioinformatics Core Facility and Clinical Genomics Linköping
+
Report version number: v0.3.0
+
+ + + + + + \ No newline at end of file From b922142782d7ce1a66103300f82082c93713f5d4 Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 13 Oct 2026 09:31:45 +0200 Subject: [PATCH 03/13] fix(dsl2): position include declarations before script statements in main.nf --- main.nf | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/main.nf b/main.nf index d1b508d..d858200 100644 --- a/main.nf +++ b/main.nf @@ -2,6 +2,10 @@ nextflow.enable.dsl=2 +include { LONGVARMETH } from './workflows/longvarmeth' +include { NANOPORE_PIPELINE } from './workflows/nanopore' +include { PACBIO_PIPELINE } from './workflows/pacbio' + def logo = """ ================================================================================ _ _ _ @@ -19,9 +23,6 @@ def logo = """ log.info logo -include { LONGVARMETH } from './workflows/longvarmeth' -include { NANOPORE_PIPELINE } from './workflows/nanopore' -include { PACBIO_PIPELINE } from './workflows/pacbio' workflow { Utils.validateParameters(params, log) From 58a8a32828893675248aeabf3759fe215f4593cc Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 13 Oct 2026 09:35:41 +0200 Subject: [PATCH 04/13] fix(dsl2): move logo string and log statement inside workflow block --- main.nf | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/main.nf b/main.nf index d858200..995faaa 100644 --- a/main.nf +++ b/main.nf @@ -6,7 +6,8 @@ include { LONGVARMETH } from './workflows/longvarmeth' include { NANOPORE_PIPELINE } from './workflows/nanopore' include { PACBIO_PIPELINE } from './workflows/pacbio' -def logo = """ +workflow { + def logo = """ ================================================================================ _ _ _ | | ___ _ __ __ _ __ ____ _ _ __ _ __ ___ ___| |_| |__ @@ -20,12 +21,10 @@ def logo = """ species : ${params.species} | mode : ${params.run_type} ================================================================================ """ + log.info logo -log.info logo - - -workflow { Utils.validateParameters(params, log) + Utils.printSummary(workflow, params, log) if (params.platform == 'pacbio') { From f8eb21d2649558207fc6c6f90fa1a793f22d48e6 Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 13 Oct 2026 09:54:24 +0200 Subject: [PATCH 05/13] fix(dsl2): move workflow.onComplete inside workflow block --- main.nf | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/main.nf b/main.nf index 995faaa..08b8600 100644 --- a/main.nf +++ b/main.nf @@ -34,10 +34,11 @@ workflow { } else { LONGVARMETH() } -} -workflow.onComplete { - Utils.pipelineCompleted(workflow, params, log) + workflow.onComplete { + Utils.pipelineCompleted(workflow, params, log) + } } + From 6ee702543746a15ae2154714710227d57e87e26d Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 13 Oct 2026 10:01:27 +0200 Subject: [PATCH 06/13] fix(visualization): align module call arguments and add process stubs --- modules/local/jbrowse/main.nf | 7 +++++++ modules/local/methylartist/main.nf | 7 +++++++ subworkflows/local/visualization/main.nf | 15 ++++++++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/modules/local/jbrowse/main.nf b/modules/local/jbrowse/main.nf index bf9b44d..4baf97b 100644 --- a/modules/local/jbrowse/main.nf +++ b/modules/local/jbrowse/main.nf @@ -31,4 +31,11 @@ process JBROWSE_CREATE { jbrowse: \$( jbrowse --version ) END_VERSIONS """ + + stub: + """ + mkdir -p ${sample_id} + touch versions.yml + """ } + diff --git a/modules/local/methylartist/main.nf b/modules/local/methylartist/main.nf index 4c52edf..31a5f98 100644 --- a/modules/local/methylartist/main.nf +++ b/modules/local/methylartist/main.nf @@ -29,4 +29,11 @@ process METHYLARTIST_LOCUS { methylartist: \$( methylartist --version 2>&1 | sed -e "s/methylartist //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_locus_plot.png + touch versions.yml + """ } + diff --git a/subworkflows/local/visualization/main.nf b/subworkflows/local/visualization/main.nf index 4268a16..3b0d8f8 100644 --- a/subworkflows/local/visualization/main.nf +++ b/subworkflows/local/visualization/main.nf @@ -21,9 +21,17 @@ workflow VISUALIZATION { def methylartist_input = tagged_bams .join(assembly_ref) .map { sample_id, bam, bai, ref_files -> - tuple(sample_id, bam, bai, ref_files[0]) + tuple( + tuple(sample_id, bam, bai), + ref_files[0], + params.methylartist_locus + ) } - METHYLARTIST_LOCUS(methylartist_input) + METHYLARTIST_LOCUS( + methylartist_input.map { bam_tuple, ref, locus -> bam_tuple }, + methylartist_input.map { bam_tuple, ref, locus -> ref }, + methylartist_input.map { bam_tuple, ref, locus -> locus } + ) ch_versions = ch_versions.mix(METHYLARTIST_LOCUS.out.versions) } @@ -32,12 +40,13 @@ workflow VISUALIZATION { .join(vcf_tbi) .join(assembly_ref) .map { sample_id, bam, bai, vcf, tbi, ref_files -> - tuple(sample_id, bam, bai, vcf, tbi, ref_files[0], ref_files[1]) + tuple(sample_id, ref_files, bam, bai, vcf, tbi) } JBROWSE_CREATE(jbrowse_input) ch_versions = ch_versions.mix(JBROWSE_CREATE.out.versions) + // 3. Clinical Methylation Dashboard (adapted from gms-nallo) if (params.enable_methylation_dashboard) { def template_file = file("${projectDir}/assets/methylation_dashboard/dashboard-template.html") From 2605d47b558ffc56b460d13afb8a3038c9155a0b Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 13 Oct 2026 10:45:14 +0200 Subject: [PATCH 07/13] fix(ci): complete stub definitions and channel wiring for ont and pacbio test profiles --- conf/modules.config | 3 ++- lib/Utils.groovy | 18 ++++++++++-------- main.nf | 3 ++- modules/local/dorado/basecall/main.nf | 6 ++++++ modules/local/dorado/demux/main.nf | 8 ++++++++ modules/local/dump_mqc_metadata/main.nf | 8 ++++++++ modules/local/jbrowse/main.nf | 2 +- modules/local/merge_bams/main.nf | 7 +++++++ modules/local/methylartist/main.nf | 2 +- modules/local/transfer_tags/main.nf | 7 +++++++ modules/nf-core/bcftools/call/main.nf | 7 +++++++ modules/nf-core/bcftools/concat/main.nf | 7 +++++++ modules/nf-core/busco/main.nf | 6 ++++++ modules/nf-core/cnvkit/main.nf | 7 +++++++ modules/nf-core/cramino/main.nf | 6 ++++++ modules/nf-core/fastqc/main.nf | 8 ++++++++ modules/nf-core/filtlong/main.nf | 6 ++++++ modules/nf-core/flye/main.nf | 9 +++++++++ modules/nf-core/hificnv/main.nf | 6 +----- modules/nf-core/hiphase/main.nf | 6 +----- modules/nf-core/medaka/main.nf | 6 ++++++ modules/nf-core/minimap2/main.nf | 6 ++++++ modules/nf-core/modkit/pileup/main.nf | 6 ++++++ modules/nf-core/multiqc/main.nf | 10 +++++++++- modules/nf-core/nanoplot/main.nf | 6 ++++++ modules/nf-core/pbmm2/align/main.nf | 6 +----- modules/nf-core/pod5/convert/main.nf | 6 ++++++ modules/nf-core/quast/main.nf | 6 ++++++ modules/nf-core/samtools/fastq/main.nf | 6 ++++++ modules/nf-core/samtools/sort/main.nf | 7 +++++++ modules/nf-core/sniffles/main.nf | 6 ++++++ modules/nf-core/snpeff/annotate/main.nf | 8 ++++++++ modules/nf-core/snpeff/download/main.nf | 6 ++++++ modules/nf-core/trgt/main.nf | 6 +----- modules/nf-core/vep/annotate/main.nf | 8 ++++++++ modules/nf-core/vep/download/main.nf | 6 ++++++ modules/nf-core/whatshap/haplotag/main.nf | 7 +++++++ modules/nf-core/whatshap/phase/main.nf | 7 +++++++ subworkflows/local/basecall_demux/main.nf | 3 ++- subworkflows/local/qc/main.nf | 7 ++++--- subworkflows/local/structural_variants/main.nf | 6 ++++-- tests/data/reads.pod5 | 0 tests/data/ref_mt_ap.fasta | 0 tests/data/ref_mt_au.fasta | 0 tests/data/ref_nuc.fasta | 0 45 files changed, 228 insertions(+), 39 deletions(-) create mode 100644 tests/data/reads.pod5 create mode 100644 tests/data/ref_mt_ap.fasta create mode 100644 tests/data/ref_mt_au.fasta create mode 100644 tests/data/ref_nuc.fasta diff --git a/conf/modules.config b/conf/modules.config index b1b3ed6..29c1c88 100644 --- a/conf/modules.config +++ b/conf/modules.config @@ -19,11 +19,12 @@ process { withName: 'NANOPLOT' { publishDir = [ - path: { "${params.outdir}/qc/nanoplot/${meta_id ?: task.index}" }, + path: { "${params.outdir}/qc/nanoplot/${sample_id ?: task.index}" }, mode: 'copy' ] } + withName: 'CRAMINO' { publishDir = [ path: { "${params.outdir}/qc/cramino" }, diff --git a/lib/Utils.groovy b/lib/Utils.groovy index 60a3bdf..2adc875 100644 --- a/lib/Utils.groovy +++ b/lib/Utils.groovy @@ -40,25 +40,27 @@ Work Dir : ${workflow.workDir} log.info summary } - public static void pipelineCompleted(workflow, params, log) { - if (workflow.success) { + public static void pipelineCompleted(wf, params, log) { + if (!wf) return + if (wf.success) { log.info """ ================================================================================ Pipeline completed successfully! - Duration : ${workflow.duration} - CPU hours: ${workflow.cpuHours ?: 'N/A'} - Output : ${workflow.launchDir}/${params.outdir} + Duration : ${wf.duration ?: 'N/A'} + CPU hours: ${wf.cpuHours ?: 'N/A'} + Output : ${wf.launchDir}/${params.outdir} ================================================================================ """ } else { log.error """ ================================================================================ Pipeline completed with ERRORS! - Error message: ${workflow.errorMessage} - Exit status : ${workflow.exitStatus} - Work dir : ${workflow.workDir} + Error message: ${wf.errorMessage ?: 'Unknown error'} + Exit status : ${wf.exitStatus ?: 'N/A'} + Work dir : ${wf.workDir ?: 'N/A'} ================================================================================ """ } } } + diff --git a/main.nf b/main.nf index 08b8600..f94acc9 100644 --- a/main.nf +++ b/main.nf @@ -36,9 +36,10 @@ workflow { } workflow.onComplete { - Utils.pipelineCompleted(workflow, params, log) + Utils.pipelineCompleted(it ?: workflow, params, log) } } + diff --git a/modules/local/dorado/basecall/main.nf b/modules/local/dorado/basecall/main.nf index 5b25197..61c44d3 100644 --- a/modules/local/dorado/basecall/main.nf +++ b/modules/local/dorado/basecall/main.nf @@ -22,4 +22,10 @@ process DORADO_BASECALL { ${mod_bases_arg} \\ > basecalled_reads.bam """ + + stub: + """ + touch basecalled_reads.bam + touch versions.yml + """ } diff --git a/modules/local/dorado/demux/main.nf b/modules/local/dorado/demux/main.nf index 86e16bb..5b4b7df 100644 --- a/modules/local/dorado/demux/main.nf +++ b/modules/local/dorado/demux/main.nf @@ -17,4 +17,12 @@ process DORADO_DEMUX { --output-dir demux \\ --threads ${task.cpus ?: params.threads} """ + + stub: + """ + mkdir -p demux/barcode01 + touch demux/barcode01/reads.bam + touch versions.yml + """ } + diff --git a/modules/local/dump_mqc_metadata/main.nf b/modules/local/dump_mqc_metadata/main.nf index e57c185..9b15933 100644 --- a/modules/local/dump_mqc_metadata/main.nf +++ b/modules/local/dump_mqc_metadata/main.nf @@ -51,4 +51,12 @@ process DUMP_MQC_METADATA { cp ${methods_description_template} methods_description_mqc.yaml """ + + stub: + """ + touch pipeline_summary_mqc.yaml + touch software_versions_mqc.yaml + touch methods_description_mqc.yaml + touch versions.yml + """ } diff --git a/modules/local/jbrowse/main.nf b/modules/local/jbrowse/main.nf index 4baf97b..8577e1c 100644 --- a/modules/local/jbrowse/main.nf +++ b/modules/local/jbrowse/main.nf @@ -34,7 +34,7 @@ process JBROWSE_CREATE { stub: """ - mkdir -p ${sample_id} + touch ${sample_id} touch versions.yml """ } diff --git a/modules/local/merge_bams/main.nf b/modules/local/merge_bams/main.nf index af00a38..873d51f 100644 --- a/modules/local/merge_bams/main.nf +++ b/modules/local/merge_bams/main.nf @@ -35,4 +35,11 @@ process MERGE_BAMS { samtools: \$( samtools --version 2>&1 | head -n 1 | sed -e "s/samtools //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_merged.bam + touch versions.yml + """ } + diff --git a/modules/local/methylartist/main.nf b/modules/local/methylartist/main.nf index 31a5f98..7447569 100644 --- a/modules/local/methylartist/main.nf +++ b/modules/local/methylartist/main.nf @@ -32,7 +32,7 @@ process METHYLARTIST_LOCUS { stub: """ - touch ${sample_id}_locus_plot.png + touch ${sample_id}_locus_sample.png touch versions.yml """ } diff --git a/modules/local/transfer_tags/main.nf b/modules/local/transfer_tags/main.nf index bbcb34b..9e7583d 100644 --- a/modules/local/transfer_tags/main.nf +++ b/modules/local/transfer_tags/main.nf @@ -22,4 +22,11 @@ process TRANSFER_TAGS { pysam: \$( python -c "import pysam; print(pysam.__version__)" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.mmtags.sorted.bam + touch ${sample_id}.mmtags.sorted.bam.bai + touch versions.yml + """ } diff --git a/modules/nf-core/bcftools/call/main.nf b/modules/nf-core/bcftools/call/main.nf index 631dd04..cd8633d 100644 --- a/modules/nf-core/bcftools/call/main.nf +++ b/modules/nf-core/bcftools/call/main.nf @@ -28,4 +28,11 @@ process BCFTOOLS_CALL { bcftools: \$( bcftools --version 2>&1 | head -n 1 | sed -e "s/bcftools //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_${region}.vcf.gz + touch ${sample_id}_${region}.vcf.gz.tbi + touch versions.yml + """ } diff --git a/modules/nf-core/bcftools/concat/main.nf b/modules/nf-core/bcftools/concat/main.nf index 16189a5..bd137f1 100644 --- a/modules/nf-core/bcftools/concat/main.nf +++ b/modules/nf-core/bcftools/concat/main.nf @@ -23,4 +23,11 @@ process BCFTOOLS_CONCAT { bcftools: \$( bcftools --version 2>&1 | head -n 1 | sed -e "s/bcftools //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.vcf.gz + touch ${sample_id}.vcf.gz.tbi + touch versions.yml + """ } diff --git a/modules/nf-core/busco/main.nf b/modules/nf-core/busco/main.nf index fd741b1..35c4123 100644 --- a/modules/nf-core/busco/main.nf +++ b/modules/nf-core/busco/main.nf @@ -30,4 +30,10 @@ process BUSCO { busco: \$( busco --version 2>&1 | head -n 1 | sed -e "s/BUSCO //g" ) END_VERSIONS """ + + stub: + """ + touch busco_${sample_id} + touch versions.yml + """ } diff --git a/modules/nf-core/cnvkit/main.nf b/modules/nf-core/cnvkit/main.nf index d41d4fd..29432d8 100644 --- a/modules/nf-core/cnvkit/main.nf +++ b/modules/nf-core/cnvkit/main.nf @@ -29,4 +29,11 @@ process CNVKIT { cnvkit: \$( cnvkit.py version | sed -e "s/cnvkit //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.cns + touch ${sample_id}-diagram.pdf + touch versions.yml + """ } diff --git a/modules/nf-core/cramino/main.nf b/modules/nf-core/cramino/main.nf index bb0efc9..bd5bf3f 100644 --- a/modules/nf-core/cramino/main.nf +++ b/modules/nf-core/cramino/main.nf @@ -22,4 +22,10 @@ process CRAMINO { cramino: \$( cramino --version | sed -e "s/cramino //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_cramino.txt + touch versions.yml + """ } diff --git a/modules/nf-core/fastqc/main.nf b/modules/nf-core/fastqc/main.nf index b318853..36e882e 100644 --- a/modules/nf-core/fastqc/main.nf +++ b/modules/nf-core/fastqc/main.nf @@ -23,4 +23,12 @@ process FASTQC { fastqc: \$( fastqc --version | sed -e "s/FastQC v//g" ) END_VERSIONS """ + + stub: + """ + touch sample_fastqc.zip + touch sample_fastqc.html + touch versions.yml + """ } + diff --git a/modules/nf-core/filtlong/main.nf b/modules/nf-core/filtlong/main.nf index 7f08bc4..1c8914f 100644 --- a/modules/nf-core/filtlong/main.nf +++ b/modules/nf-core/filtlong/main.nf @@ -26,4 +26,10 @@ process FILTLONG { filtlong: \$( filtlong --version | sed -e "s/Filtlong v//g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_filtered.fastq.gz + touch versions.yml + """ } diff --git a/modules/nf-core/flye/main.nf b/modules/nf-core/flye/main.nf index 6dcf774..ab4988e 100644 --- a/modules/nf-core/flye/main.nf +++ b/modules/nf-core/flye/main.nf @@ -23,4 +23,13 @@ process FLYE { flye: \$( flye --version ) END_VERSIONS """ + + stub: + """ + mkdir -p ${sample_id} + touch ${sample_id}/assembly.fasta + touch ${sample_id}/sample + touch versions.yml + """ } + diff --git a/modules/nf-core/hificnv/main.nf b/modules/nf-core/hificnv/main.nf index 83b8894..40edff1 100644 --- a/modules/nf-core/hificnv/main.nf +++ b/modules/nf-core/hificnv/main.nf @@ -36,10 +36,6 @@ process HIFINCNV { touch ${sample_id}.hificnv.vcf.gz touch ${sample_id}.hificnv.vcf.gz.tbi touch ${sample_id}.hificnv.copynumber.bedgraph - - cat <<-END_VERSIONS > versions.yml - "${task.process}": - hificnv: 1.0.0 - END_VERSIONS + touch versions.yml """ } diff --git a/modules/nf-core/hiphase/main.nf b/modules/nf-core/hiphase/main.nf index abab8c8..c6ad972 100644 --- a/modules/nf-core/hiphase/main.nf +++ b/modules/nf-core/hiphase/main.nf @@ -41,10 +41,6 @@ process HIPHASE { touch ${sample_id}.small_phased.vcf.gz.tbi touch ${sample_id}.sv_phased.vcf.gz touch ${sample_id}.sv_phased.vcf.gz.tbi - - cat <<-END_VERSIONS > versions.yml - "${task.process}": - hiphase: 1.4.0 - END_VERSIONS + touch versions.yml """ } diff --git a/modules/nf-core/medaka/main.nf b/modules/nf-core/medaka/main.nf index f4db601..8ee9451 100644 --- a/modules/nf-core/medaka/main.nf +++ b/modules/nf-core/medaka/main.nf @@ -31,4 +31,10 @@ process MEDAKA { medaka: \$( medaka --version | sed -e "s/medaka //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_polished.fasta + touch versions.yml + """ } diff --git a/modules/nf-core/minimap2/main.nf b/modules/nf-core/minimap2/main.nf index 193f14b..fbb0ff3 100644 --- a/modules/nf-core/minimap2/main.nf +++ b/modules/nf-core/minimap2/main.nf @@ -23,4 +23,10 @@ process MINIMAP2 { minimap2: \$( minimap2 --version ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.sam + touch versions.yml + """ } diff --git a/modules/nf-core/modkit/pileup/main.nf b/modules/nf-core/modkit/pileup/main.nf index 909b5f7..ebeb261 100644 --- a/modules/nf-core/modkit/pileup/main.nf +++ b/modules/nf-core/modkit/pileup/main.nf @@ -23,4 +23,10 @@ process MODKIT_PILEUP { modkit: \$( modkit --version | sed -e "s/modkit //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_mods.bed + touch versions.yml + """ } diff --git a/modules/nf-core/multiqc/main.nf b/modules/nf-core/multiqc/main.nf index 9ef8399..c7cfadd 100644 --- a/modules/nf-core/multiqc/main.nf +++ b/modules/nf-core/multiqc/main.nf @@ -5,9 +5,10 @@ process MULTIQC { container "quay.io/biocontainers/multiqc:1.18--pyhdfd78af_0" input: - path 'qc_files/*' + path(qc_files, stageAs: "?/*") path(multiqc_config) + output: path "*multiqc_report.html", emit: report path "*_data" , emit: data @@ -24,4 +25,11 @@ process MULTIQC { multiqc: \$( multiqc --version | sed -e "s/multiqc, version //g" ) END_VERSIONS """ + + stub: + """ + touch samplemultiqc_report.html + touch sample_data + touch versions.yml + """ } diff --git a/modules/nf-core/nanoplot/main.nf b/modules/nf-core/nanoplot/main.nf index 8fda63f..db8e784 100644 --- a/modules/nf-core/nanoplot/main.nf +++ b/modules/nf-core/nanoplot/main.nf @@ -28,4 +28,10 @@ process NANOPLOT { nanoplot: \$( NanoPlot --version | sed -e "s/NanoPlot //g" ) END_VERSIONS """ + + stub: + """ + touch nanoplot_${sample_id} + touch versions.yml + """ } diff --git a/modules/nf-core/pbmm2/align/main.nf b/modules/nf-core/pbmm2/align/main.nf index f49cff9..fdcadd9 100644 --- a/modules/nf-core/pbmm2/align/main.nf +++ b/modules/nf-core/pbmm2/align/main.nf @@ -28,10 +28,6 @@ process PBMM2_ALIGN { """ touch ${sample_id}.aligned.bam touch ${sample_id}.aligned.bam.bai - - cat <<-END_VERSIONS > versions.yml - "${task.process}": - pbmm2: 1.13.0 - END_VERSIONS + touch versions.yml """ } diff --git a/modules/nf-core/pod5/convert/main.nf b/modules/nf-core/pod5/convert/main.nf index 0e74788..38a8008 100644 --- a/modules/nf-core/pod5/convert/main.nf +++ b/modules/nf-core/pod5/convert/main.nf @@ -23,4 +23,10 @@ process POD5_CONVERT { pod5: \$( pod5 --version 2>&1 | sed -e "s/pod5 //g" ) END_VERSIONS """ + + stub: + """ + touch converted_pod5 + touch versions.yml + """ } diff --git a/modules/nf-core/quast/main.nf b/modules/nf-core/quast/main.nf index 6481db0..4e9209d 100644 --- a/modules/nf-core/quast/main.nf +++ b/modules/nf-core/quast/main.nf @@ -22,4 +22,10 @@ process QUAST { quast: \$( quast.py --version | sed -e "s/QUAST v//g" ) END_VERSIONS """ + + stub: + """ + touch quast_${sample_id} + touch versions.yml + """ } diff --git a/modules/nf-core/samtools/fastq/main.nf b/modules/nf-core/samtools/fastq/main.nf index 613ab1e..f57edd1 100644 --- a/modules/nf-core/samtools/fastq/main.nf +++ b/modules/nf-core/samtools/fastq/main.nf @@ -22,4 +22,10 @@ process SAMTOOLS_FASTQ { samtools: \$( samtools --version 2>&1 | head -n 1 | sed -e "s/samtools //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.fastq.gz + touch versions.yml + """ } diff --git a/modules/nf-core/samtools/sort/main.nf b/modules/nf-core/samtools/sort/main.nf index b6fa379..7dbd798 100644 --- a/modules/nf-core/samtools/sort/main.nf +++ b/modules/nf-core/samtools/sort/main.nf @@ -23,4 +23,11 @@ process SAMTOOLS_SORT { samtools: \$( samtools --version 2>&1 | head -n 1 | sed -e "s/samtools //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.sorted.bam + touch ${sample_id}.sorted.bam.bai + touch versions.yml + """ } diff --git a/modules/nf-core/sniffles/main.nf b/modules/nf-core/sniffles/main.nf index e0ba92c..4083f9d 100644 --- a/modules/nf-core/sniffles/main.nf +++ b/modules/nf-core/sniffles/main.nf @@ -23,4 +23,10 @@ process SNIFFLES { sniffles: \$( sniffles --version ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}_svs.vcf.gz + touch versions.yml + """ } diff --git a/modules/nf-core/snpeff/annotate/main.nf b/modules/nf-core/snpeff/annotate/main.nf index 188a190..6f5c28b 100644 --- a/modules/nf-core/snpeff/annotate/main.nf +++ b/modules/nf-core/snpeff/annotate/main.nf @@ -32,4 +32,12 @@ process SNPEFF { snpeff: \$( snpEff -version 2>&1 | head -n 1 | sed -e "s/snpEff //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.snpeff.vcf + touch ${sample_id}.snpeff.genes.txt + touch ${sample_id}.snpeff.summary.html + touch versions.yml + """ } diff --git a/modules/nf-core/snpeff/download/main.nf b/modules/nf-core/snpeff/download/main.nf index 1e03b63..2cae0b9 100644 --- a/modules/nf-core/snpeff/download/main.nf +++ b/modules/nf-core/snpeff/download/main.nf @@ -26,4 +26,10 @@ process SNPEFF_DOWNLOAD { snpeff: \$( snpEff -version 2>&1 | head -n 1 | sed -e "s/snpEff //g" ) END_VERSIONS """ + + stub: + """ + touch snpeff_db_ready.txt + touch versions.yml + """ } diff --git a/modules/nf-core/trgt/main.nf b/modules/nf-core/trgt/main.nf index ce54256..4e21b80 100644 --- a/modules/nf-core/trgt/main.nf +++ b/modules/nf-core/trgt/main.nf @@ -39,10 +39,6 @@ process TRGT { touch ${sample_id}.trgt.vcf.gz touch ${sample_id}.trgt.vcf.gz.tbi touch ${sample_id}.spanning.bam - - cat <<-END_VERSIONS > versions.yml - "${task.process}": - trgt: 1.0.0 - END_VERSIONS + touch versions.yml """ } diff --git a/modules/nf-core/vep/annotate/main.nf b/modules/nf-core/vep/annotate/main.nf index f5da840..e6fd81b 100644 --- a/modules/nf-core/vep/annotate/main.nf +++ b/modules/nf-core/vep/annotate/main.nf @@ -39,4 +39,12 @@ process VEP { vep: \$( vep --help 2>&1 | grep "ensembl-vep" | head -n 1 | sed -e "s/.*: //g" ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.vep.vcf.gz + touch ${sample_id}.vep.vcf.gz.tbi + touch ${sample_id}.vep.summary.html + touch versions.yml + """ } diff --git a/modules/nf-core/vep/download/main.nf b/modules/nf-core/vep/download/main.nf index 453e61d..06d16bf 100644 --- a/modules/nf-core/vep/download/main.nf +++ b/modules/nf-core/vep/download/main.nf @@ -25,4 +25,10 @@ process VEP_DOWNLOAD { vep: \$( vep --help 2>&1 | grep "ensembl-vep" | head -n 1 | sed -e "s/.*: //g" ) END_VERSIONS """ + + stub: + """ + touch vep_db_ready.txt + touch versions.yml + """ } diff --git a/modules/nf-core/whatshap/haplotag/main.nf b/modules/nf-core/whatshap/haplotag/main.nf index 63c31c7..c698e8e 100644 --- a/modules/nf-core/whatshap/haplotag/main.nf +++ b/modules/nf-core/whatshap/haplotag/main.nf @@ -32,4 +32,11 @@ process WHATSHAP_HAPLOTAG { whatshap: \$( whatshap --version ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.haplotagged.bam + touch ${sample_id}.haplotagged.bam.bai + touch versions.yml + """ } diff --git a/modules/nf-core/whatshap/phase/main.nf b/modules/nf-core/whatshap/phase/main.nf index 4f2d95d..484d8fa 100644 --- a/modules/nf-core/whatshap/phase/main.nf +++ b/modules/nf-core/whatshap/phase/main.nf @@ -33,4 +33,11 @@ process WHATSHAP_PHASE { whatshap: \$( whatshap --version ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.phased.vcf.gz + touch ${sample_id}.phased.vcf.gz.tbi + touch versions.yml + """ } diff --git a/subworkflows/local/basecall_demux/main.nf b/subworkflows/local/basecall_demux/main.nf index b5b801c..4cbde3e 100644 --- a/subworkflows/local/basecall_demux/main.nf +++ b/subworkflows/local/basecall_demux/main.nf @@ -20,10 +20,11 @@ workflow BASECALL_DEMUX { .join(fast5_runs.map { tuple(it[0], it[2], it[3], it[4], it[5]) }) .map { tuple(it[0], it[1], it[3], it[4], it[5]) } - def all_pod5_runs = pod5_runs.map { tuple(it[0], it[1], it[3], it[4], it[5]) } + def all_pod5_runs = pod5_runs.map { tuple(it[0], file(it[1], checkIfExists: false), it[3], it[4], it[5]) } .concat(converted_runs) DORADO_BASECALL(all_pod5_runs) + def demux_inputs = DORADO_BASECALL.out.bam .join(samples_ch.map { tuple(it[0], it[3]) }) diff --git a/subworkflows/local/qc/main.nf b/subworkflows/local/qc/main.nf index 6e416a3..e3facd2 100644 --- a/subworkflows/local/qc/main.nf +++ b/subworkflows/local/qc/main.nf @@ -32,8 +32,9 @@ workflow READ_QC { emit: fastq = fastq_ch - cramino_files = CRAMINO.out.tsv - fastqc_files = FASTQC.out.zip - nanoplot_files = NANOPLOT.out.txt + cramino_files = CRAMINO.out.report + fastqc_files = FASTQC.out.qc_files + nanoplot_files = NANOPLOT.out.report versions = ch_versions + } diff --git a/subworkflows/local/structural_variants/main.nf b/subworkflows/local/structural_variants/main.nf index c23691e..704318d 100644 --- a/subworkflows/local/structural_variants/main.nf +++ b/subworkflows/local/structural_variants/main.nf @@ -20,7 +20,8 @@ workflow STRUCTURAL_VARIANTS { // 1. Structural Variants (Sniffles2) SNIFFLES(aligned_bam_bai, ref_nuc_path) ch_versions = ch_versions.mix(SNIFFLES.out.versions) - ch_sv_vcf = SNIFFLES.out.vcf_tbi + ch_sv_vcf = SNIFFLES.out.sv_vcf + // 2. Copy Number Alterations (CNVkit / HiFiCNV) if (params.platform == 'pacbio' && params.cnv_caller == 'hificnv') { @@ -30,9 +31,10 @@ workflow STRUCTURAL_VARIANTS { } else { CNVKIT(aligned_bam_bai, ref_nuc_path) ch_versions = ch_versions.mix(CNVKIT.out.versions) - ch_cnv_calls = CNVKIT.out.cns + ch_cnv_calls = CNVKIT.out.cnv_files } + // 3. Short Tandem Repeats (TRGT) if (params.enable_trgt && params.repeat_bed) { def repeat_bed_file = file(params.repeat_bed) diff --git a/tests/data/reads.pod5 b/tests/data/reads.pod5 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/ref_mt_ap.fasta b/tests/data/ref_mt_ap.fasta new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/ref_mt_au.fasta b/tests/data/ref_mt_au.fasta new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/ref_nuc.fasta b/tests/data/ref_nuc.fasta new file mode 100644 index 0000000..e69de29 From 928827720978d14940c2bc869d291bc4d4a0587a Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Thu, 15 Oct 2026 13:37:49 +0200 Subject: [PATCH 08/13] feat: add mosdepth coverage, QC, enable snfeff for humna, symlink jbrowse, and add skip_jbrowse --- conf/human.config | 5 ++++ conf/modules.config | 21 ++++++++++++++ conf/params.config | 3 ++ conf/version_lock.config | 1 + lib/Utils.groovy | 3 +- modules/local/jbrowse/main.nf | 6 ++-- modules/nf-core/mosdepth/environment.yml | 7 +++++ modules/nf-core/mosdepth/main.nf | 37 ++++++++++++++++++++++++ subworkflows/local/visualization/main.nf | 22 ++++++++------ versions.yml | 1 + workflows/longvarmeth.nf | 7 +++++ workflows/nanopore.nf | 7 +++++ workflows/pacbio.nf | 7 +++++ 13 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 modules/nf-core/mosdepth/environment.yml create mode 100644 modules/nf-core/mosdepth/main.nf diff --git a/conf/human.config b/conf/human.config index bdc75ab..3bf4487 100644 --- a/conf/human.config +++ b/conf/human.config @@ -16,4 +16,9 @@ params { // Enable clinical dashboard for human runs enable_methylation_dashboard = true + + // Enable variant effect annotation for human runs + enable_snpeff = true + snpeff_db = "GRCh38.105" + snpeff_cache = "/data/Jyotirmoy/Reference_data/snpEff" } diff --git a/conf/modules.config b/conf/modules.config index 29c1c88..59950d0 100644 --- a/conf/modules.config +++ b/conf/modules.config @@ -149,6 +149,27 @@ process { ] } + withName: '.*SNPEFF.*' { + publishDir = [ + path: { "${params.outdir}/annotated_variants" }, + mode: 'copy' + ] + } + + withName: '.*VEP.*' { + publishDir = [ + path: { "${params.outdir}/annotated_variants" }, + mode: 'copy' + ] + } + + withName: '.*MOSDEPTH.*' { + publishDir = [ + path: { "${params.outdir}/qc/mosdepth" }, + mode: 'copy' + ] + } + withName: 'PBMM2_ALIGN' { publishDir = [ path: { "${params.outdir}/alignments/pbmm2" }, diff --git a/conf/params.config b/conf/params.config index 7264164..014a3ca 100755 --- a/conf/params.config +++ b/conf/params.config @@ -66,4 +66,7 @@ params { // Clinical Methylation Dashboard (adapted from gms-nallo) enable_methylation_dashboard = false + + // Visualization options + skip_jbrowse = false } diff --git a/conf/version_lock.config b/conf/version_lock.config index 490cfcb..9b794d6 100644 --- a/conf/version_lock.config +++ b/conf/version_lock.config @@ -25,4 +25,5 @@ process { withName: '.*MODKIT_PILEUP.*' { container = 'quay.io/biocontainers/modkit:0.2.4--h9a82719_0' } withName: '.*JBROWSE_CREATE' { container = 'quay.io/biocontainers/jbrowse2:4.3.0--hdfd78af_0' } withName: '.*MULTIQC' { container = 'quay.io/biocontainers/multiqc:1.18--pyhdfd78af_0' } + withName: '.*MOSDEPTH' { container = 'quay.io/biocontainers/mosdepth:0.3.8--hd299d5a_0' } } diff --git a/lib/Utils.groovy b/lib/Utils.groovy index 2adc875..e3f3230 100644 --- a/lib/Utils.groovy +++ b/lib/Utils.groovy @@ -43,11 +43,12 @@ Work Dir : ${workflow.workDir} public static void pipelineCompleted(wf, params, log) { if (!wf) return if (wf.success) { + def cpuHours = wf.stats?.computeTimeFmt ?: (wf.stats?.cpuTime ? String.format("%.2f h", wf.stats.cpuTime / 3600000.0) : (wf.cpuHours ?: 'N/A')) log.info """ ================================================================================ Pipeline completed successfully! Duration : ${wf.duration ?: 'N/A'} - CPU hours: ${wf.cpuHours ?: 'N/A'} + CPU hours: ${cpuHours} Output : ${wf.launchDir}/${params.outdir} ================================================================================ """ diff --git a/modules/local/jbrowse/main.nf b/modules/local/jbrowse/main.nf index 8577e1c..80f1dc1 100644 --- a/modules/local/jbrowse/main.nf +++ b/modules/local/jbrowse/main.nf @@ -17,13 +17,13 @@ process JBROWSE_CREATE { """ jbrowse create ${sample_id} if [ -f "${fasta}" ]; then - jbrowse add-assembly ${fasta} --load copy --name "${sample_id}_assembly" --out ${sample_id} + jbrowse add-assembly ${fasta} --load symlink --name "${sample_id}_assembly" --out ${sample_id} fi if [ -f "${bam}" ]; then - jbrowse add-track ${bam} --load copy --name "${sample_id}_bam" --out ${sample_id} + jbrowse add-track ${bam} --load symlink --name "${sample_id}_bam" --out ${sample_id} fi if [ -f "${vcf}" ]; then - jbrowse add-track ${vcf} --load copy --name "${sample_id}_vcf" --out ${sample_id} + jbrowse add-track ${vcf} --load symlink --name "${sample_id}_vcf" --out ${sample_id} fi cat <<-END_VERSIONS > versions.yml diff --git a/modules/nf-core/mosdepth/environment.yml b/modules/nf-core/mosdepth/environment.yml new file mode 100644 index 0000000..aafcfb0 --- /dev/null +++ b/modules/nf-core/mosdepth/environment.yml @@ -0,0 +1,7 @@ +name: mosdepth +channels: + - conda-forge + - bioconda + - defaults +dependencies: + - bioconda::mosdepth=0.3.8 diff --git a/modules/nf-core/mosdepth/main.nf b/modules/nf-core/mosdepth/main.nf new file mode 100644 index 0000000..5d3ac08 --- /dev/null +++ b/modules/nf-core/mosdepth/main.nf @@ -0,0 +1,37 @@ +process MOSDEPTH { + tag "$sample_id" + label 'process_medium' + + conda "${moduleDir}/environment.yml" + container "quay.io/biocontainers/mosdepth:0.3.8--hd299d5a_0" + + input: + tuple val(sample_id), path(bam), path(bai) + path fasta + + output: + tuple val(sample_id), path("${sample_id}.mosdepth.global.dist.txt"), emit: global_txt + tuple val(sample_id), path("${sample_id}.mosdepth.region.dist.txt"), emit: regions_txt, optional: true + tuple val(sample_id), path("${sample_id}.mosdepth.summary.txt") , emit: summary_txt + tuple val(sample_id), path("${sample_id}.per-base.bed.gz") , emit: per_base_bed, optional: true + tuple val(sample_id), path("${sample_id}.regions.bed.gz") , emit: regions_bed, optional: true + tuple val(sample_id), path("${sample_id}.quantized.bed.gz") , emit: quantized_bed, optional: true + path "versions.yml" , emit: versions + + script: + def args = task.ext.args ?: '' + def reference = fasta ? "--fasta ${fasta}" : "" + """ + mosdepth \\ + --threads ${task.cpus} \\ + ${reference} \\ + ${args} \\ + ${sample_id} \\ + ${bam} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + mosdepth: \$( mosdepth --version 2>&1 | sed 's/mosdepth //g' ) + END_VERSIONS + """ +} diff --git a/subworkflows/local/visualization/main.nf b/subworkflows/local/visualization/main.nf index 3b0d8f8..edbe2fe 100644 --- a/subworkflows/local/visualization/main.nf +++ b/subworkflows/local/visualization/main.nf @@ -36,15 +36,19 @@ workflow VISUALIZATION { } // 2. Automated JBrowse 2 Directory Generation - def jbrowse_input = tagged_bams - .join(vcf_tbi) - .join(assembly_ref) - .map { sample_id, bam, bai, vcf, tbi, ref_files -> - tuple(sample_id, ref_files, bam, bai, vcf, tbi) - } + def ch_jbrowse = Channel.empty() + if (!params.skip_jbrowse) { + def jbrowse_input = tagged_bams + .join(vcf_tbi) + .join(assembly_ref) + .map { sample_id, bam, bai, vcf, tbi, ref_files -> + tuple(sample_id, ref_files, bam, bai, vcf, tbi) + } - JBROWSE_CREATE(jbrowse_input) - ch_versions = ch_versions.mix(JBROWSE_CREATE.out.versions) + JBROWSE_CREATE(jbrowse_input) + ch_versions = ch_versions.mix(JBROWSE_CREATE.out.versions) + ch_jbrowse = JBROWSE_CREATE.out.jbrowse_dir + } // 3. Clinical Methylation Dashboard (adapted from gms-nallo) @@ -56,7 +60,7 @@ workflow VISUALIZATION { } emit: - jbrowse_dir = JBROWSE_CREATE.out.jbrowse_dir + jbrowse_dir = ch_jbrowse methylation_html = ch_reports versions = ch_versions } diff --git a/versions.yml b/versions.yml index e377a3f..1aee064 100644 --- a/versions.yml +++ b/versions.yml @@ -22,6 +22,7 @@ tools: vep: '116.0' jbrowse2: '4.3.0' multiqc: '1.18' + mosdepth: '0.3.8' cramino: '0.13.0' fastqc: '0.12.1' nanoplot: '1.42.0' diff --git a/workflows/longvarmeth.nf b/workflows/longvarmeth.nf index b5a7cf4..5e7f034 100644 --- a/workflows/longvarmeth.nf +++ b/workflows/longvarmeth.nf @@ -16,6 +16,7 @@ include { CLINICAL_GOVERNANCE } from '../subworkflows/local/governance' include { VISUALIZATION } from '../subworkflows/local/visualization' include { REPORTING } from '../subworkflows/local/reporting' include { MERGE_BAMS } from '../modules/local/merge_bams' +include { MOSDEPTH } from '../modules/nf-core/mosdepth' workflow LONGVARMETH { // Check samplesheet exists @@ -124,6 +125,12 @@ workflow LONGVARMETH { aligned_bams = aligned_bams.mix(ALIGNMENT.out.aligned_bam_bai) } + // Post-alignment Coverage QC (Mosdepth) + MOSDEPTH(aligned_bams, ref_nuc_path) + ch_versions = ch_versions.mix(MOSDEPTH.out.versions) + ch_qc_files = ch_qc_files.concat(MOSDEPTH.out.global_txt.map { it[1] }) + .concat(MOSDEPTH.out.summary_txt.map { it[1] }) + // --------------------------------------------------------------------- // STEP 5: Small Variant Calling (SNVs & InDels via BCFtools / DeepVariant) // --------------------------------------------------------------------- diff --git a/workflows/nanopore.nf b/workflows/nanopore.nf index 0f3a46a..30a653b 100644 --- a/workflows/nanopore.nf +++ b/workflows/nanopore.nf @@ -15,6 +15,7 @@ include { CLINICAL_GOVERNANCE } from '../subworkflows/local/governance' include { VISUALIZATION } from '../subworkflows/local/visualization' include { REPORTING } from '../subworkflows/local/reporting' include { MERGE_BAMS } from '../modules/local/merge_bams' +include { MOSDEPTH } from '../modules/nf-core/mosdepth' workflow NANOPORE_PIPELINE { def samplesheet_file = file(params.input) @@ -106,6 +107,12 @@ workflow NANOPORE_PIPELINE { aligned_bams = aligned_bams.mix(ALIGNMENT.out.aligned_bam_bai) } + // Post-alignment Coverage QC (Mosdepth) + MOSDEPTH(aligned_bams, ref_nuc_path) + ch_versions = ch_versions.mix(MOSDEPTH.out.versions) + ch_qc_files = ch_qc_files.concat(MOSDEPTH.out.global_txt.map { it[1] }) + .concat(MOSDEPTH.out.summary_txt.map { it[1] }) + // Step 5: Small Variants def small_vcfs = Channel.empty() if (!params.skip_variants) { diff --git a/workflows/pacbio.nf b/workflows/pacbio.nf index 9d1b0dc..64345cc 100644 --- a/workflows/pacbio.nf +++ b/workflows/pacbio.nf @@ -14,6 +14,7 @@ include { CLINICAL_GOVERNANCE } from '../subworkflows/local/governance' include { VISUALIZATION } from '../subworkflows/local/visualization' include { REPORTING } from '../subworkflows/local/reporting' include { MERGE_BAMS } from '../modules/local/merge_bams' +include { MOSDEPTH } from '../modules/nf-core/mosdepth' workflow PACBIO_PIPELINE { def samplesheet_file = file(params.input) @@ -96,6 +97,12 @@ workflow PACBIO_PIPELINE { aligned_bams = aligned_bams.mix(ALIGNMENT.out.aligned_bam_bai) } + // Post-alignment Coverage QC (Mosdepth) + MOSDEPTH(aligned_bams, ref_nuc_path) + ch_versions = ch_versions.mix(MOSDEPTH.out.versions) + ch_qc_files = ch_qc_files.concat(MOSDEPTH.out.global_txt.map { it[1] }) + .concat(MOSDEPTH.out.summary_txt.map { it[1] }) + // Step 5: Small Variants def small_vcfs = Channel.empty() if (!params.skip_variants) { From 7997a9b60fc702f64f7909cdfc94f2f41ab2f631 Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Thu, 15 Oct 2026 14:30:43 +0200 Subject: [PATCH 09/13] fix(ci): add stub block to mosdepth module for dry-run tests --- modules/nf-core/mosdepth/main.nf | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/nf-core/mosdepth/main.nf b/modules/nf-core/mosdepth/main.nf index 5d3ac08..b65c089 100644 --- a/modules/nf-core/mosdepth/main.nf +++ b/modules/nf-core/mosdepth/main.nf @@ -34,4 +34,15 @@ process MOSDEPTH { mosdepth: \$( mosdepth --version 2>&1 | sed 's/mosdepth //g' ) END_VERSIONS """ + + stub: + """ + touch ${sample_id}.mosdepth.global.dist.txt + touch ${sample_id}.mosdepth.region.dist.txt + touch ${sample_id}.mosdepth.summary.txt + touch ${sample_id}.per-base.bed.gz + touch ${sample_id}.regions.bed.gz + touch ${sample_id}.quantized.bed.gz + touch versions.yml + """ } From c06518346086c959f467e02398c28c8bcc4199af Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Mon, 19 Oct 2026 20:05:23 +0200 Subject: [PATCH 10/13] fix(pipeline): resolve governance commitId, samtools container, and methylation dashboard channel types --- modules/local/methylation_dashboard/main.nf | 14 +++++++------- subworkflows/local/governance/main.nf | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/local/methylation_dashboard/main.nf b/modules/local/methylation_dashboard/main.nf index b6058b6..cad06e1 100644 --- a/modules/local/methylation_dashboard/main.nf +++ b/modules/local/methylation_dashboard/main.nf @@ -1,5 +1,5 @@ process METHYLATION_DASHBOARD { - tag "$meta.id" + tag "$sample_id" label 'process_single' conda "conda-forge::python=3.10" @@ -8,24 +8,24 @@ process METHYLATION_DASHBOARD { 'biocontainers/python:3.10' }" input: - tuple val(meta), path(profile_tsv) + tuple val(sample_id), path(profile_tsv) path template_html output: - tuple val(meta), path("*.html"), emit: html - path "versions.yml" , emit: versions + tuple val(sample_id), path("*.html"), emit: html + path "versions.yml" , emit: versions when: task.ext.when == null || task.ext.when script: def args = task.ext.args ?: '' - def prefix = task.ext.prefix ?: "${meta.id}" + def prefix = task.ext.prefix ?: "${sample_id}" """ generate_methylation_dashboard.py \\ --profile "${profile_tsv}" \\ --template "${template_html}" \\ - --sample_id "${meta.id}" \\ + --sample_id "${sample_id}" \\ --output "${prefix}_methylation_dashboard.html" \\ ${args} @@ -37,7 +37,7 @@ process METHYLATION_DASHBOARD { """ stub: - def prefix = task.ext.prefix ?: "${meta.id}" + def prefix = task.ext.prefix ?: "${sample_id}" """ touch "${prefix}_methylation_dashboard.html" diff --git a/subworkflows/local/governance/main.nf b/subworkflows/local/governance/main.nf index 6562850..3831726 100644 --- a/subworkflows/local/governance/main.nf +++ b/subworkflows/local/governance/main.nf @@ -6,8 +6,8 @@ process SANITIZE_METADATA { tag "$sample_id" label 'process_single' - conda "bioconda::samtools=1.19.2 bioconda::bcftools=1.19" - container "quay.io/biocontainers/mulled-v2-42f3ea21636d68b3a3822d52504b022089b21618:0f8ab8c73856d10f2142e05f63d662c5fb3ea8c7-0" + conda "bioconda::samtools=1.23.1 bioconda::bcftools=1.19" + container "quay.io/biocontainers/samtools:1.23.1--ha83d96e_0" input: tuple val(sample_id), path(bam), path(bai) @@ -78,7 +78,7 @@ process GENERATE_AUDIT_MANIFEST { "pipeline": "longvarmeth", "sample_id": "${sample_id}", "timestamp_utc": "\$(date -u +'%Y-%m-%dT%H:%M:%SZ')", - "workflow_commit": "${workflow.commit ?: 'dev'}", + "workflow_commit": "${workflow.commitId ?: 'dev'}", "workflow_version": "${workflow.manifest.version ?: '1.0.0'}", "parameters": { "platform": "${params.platform}", From 3c1027b049166f111a1bc7af379d15b5b6b18074 Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Tue, 27 Oct 2026 14:15:06 +0100 Subject: [PATCH 11/13] (fix): meta updated --- conf/eukaryote.config | 6 +++--- modules/local/dorado/meta.yml | 2 +- modules/local/dump_mqc_metadata/meta.yml | 2 +- modules/local/jbrowse/meta.yml | 2 +- modules/local/merge_bams/meta.yml | 2 +- modules/local/methylartist/meta.yml | 2 +- modules/local/transfer_tags/meta.yml | 2 +- modules/nf-core/bcftools/meta.yml | 2 +- modules/nf-core/busco/meta.yml | 2 +- modules/nf-core/cnvkit/meta.yml | 2 +- modules/nf-core/cramino/meta.yml | 2 +- modules/nf-core/fastqc/meta.yml | 2 +- modules/nf-core/filtlong/meta.yml | 2 +- modules/nf-core/flye/meta.yml | 2 +- modules/nf-core/medaka/meta.yml | 2 +- modules/nf-core/minimap2/meta.yml | 2 +- modules/nf-core/modkit/meta.yml | 2 +- modules/nf-core/multiqc/meta.yml | 2 +- modules/nf-core/nanoplot/meta.yml | 2 +- modules/nf-core/pod5/meta.yml | 2 +- modules/nf-core/quast/meta.yml | 2 +- modules/nf-core/samtools/meta.yml | 2 +- modules/nf-core/sniffles/meta.yml | 2 +- modules/nf-core/snpeff/meta.yml | 2 +- modules/nf-core/vep/meta.yml | 2 +- modules/nf-core/whatshap/meta.yml | 2 +- samplesheet.csv | 9 +-------- subworkflows/local/basecall_demux/meta.yml | 2 +- 28 files changed, 30 insertions(+), 37 deletions(-) diff --git a/conf/eukaryote.config b/conf/eukaryote.config index 74495c9..2d5900a 100644 --- a/conf/eukaryote.config +++ b/conf/eukaryote.config @@ -5,9 +5,9 @@ params { run_type = "reference_based" // Eukaryote references (Artemia default) - ref_nuc = "/data/Jyotirmoy/CF-Projects/External/Parisa/CFFMHS-QU-12/artemia/reference_genome/GCF_032884065.1_ASM3288406v1_genomic.fna" - ref_mt_ap = "/data/Jyotirmoy/CF-Projects/External/Parisa/CFFMHS-QU-12/artemia/reference_genome/OR423215_1.fasta" - ref_mt_au = "/data/Jyotirmoy/CF-Projects/External/Parisa/CFFMHS-QU-12/artemia/reference_genome/MN240408_1.fasta" + ref_nuc = "/data/Jyotirmoy//GCF_032884065.1_ASM3288406v1_genomic.fna" + ref_mt_ap = "/data/Jyotirmoy//OR423215_1.fasta" + ref_mt_au = "/data/Jyotirmoy//MN240408_1.fasta" // Eukaryote specific variant parameters ploidy = 2 diff --git a/modules/local/dorado/meta.yml b/modules/local/dorado/meta.yml index 6aec4d6..8b5fb23 100644 --- a/modules/local/dorado/meta.yml +++ b/modules/local/dorado/meta.yml @@ -10,4 +10,4 @@ tools: description: Dorado is Oxford Nanopore's open-source basecaller. homepage: https://github.com/nanoporetech/dorado authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/local/dump_mqc_metadata/meta.yml b/modules/local/dump_mqc_metadata/meta.yml index aa82dd4..9028df2 100644 --- a/modules/local/dump_mqc_metadata/meta.yml +++ b/modules/local/dump_mqc_metadata/meta.yml @@ -22,4 +22,4 @@ output: type: file description: Methods section for MultiQC authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/local/jbrowse/meta.yml b/modules/local/jbrowse/meta.yml index d3301b4..d271523 100644 --- a/modules/local/jbrowse/meta.yml +++ b/modules/local/jbrowse/meta.yml @@ -37,4 +37,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/local/merge_bams/meta.yml b/modules/local/merge_bams/meta.yml index cf1e744..089cfd5 100644 --- a/modules/local/merge_bams/meta.yml +++ b/modules/local/merge_bams/meta.yml @@ -25,4 +25,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/local/methylartist/meta.yml b/modules/local/methylartist/meta.yml index 98be316..4cc9bb2 100644 --- a/modules/local/methylartist/meta.yml +++ b/modules/local/methylartist/meta.yml @@ -34,4 +34,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/local/transfer_tags/meta.yml b/modules/local/transfer_tags/meta.yml index dbd2e9c..e54728e 100644 --- a/modules/local/transfer_tags/meta.yml +++ b/modules/local/transfer_tags/meta.yml @@ -34,4 +34,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/bcftools/meta.yml b/modules/nf-core/bcftools/meta.yml index b20ff61..4bb3360 100644 --- a/modules/nf-core/bcftools/meta.yml +++ b/modules/nf-core/bcftools/meta.yml @@ -10,4 +10,4 @@ tools: description: Set of utilities that manipulate variant calls in the Variant Call Format (VCF). homepage: http://samtools.github.io/bcftools/ authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/busco/meta.yml b/modules/nf-core/busco/meta.yml index 9ad8d58..acb3daa 100644 --- a/modules/nf-core/busco/meta.yml +++ b/modules/nf-core/busco/meta.yml @@ -26,4 +26,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/cnvkit/meta.yml b/modules/nf-core/cnvkit/meta.yml index b614395..466cd83 100644 --- a/modules/nf-core/cnvkit/meta.yml +++ b/modules/nf-core/cnvkit/meta.yml @@ -34,4 +34,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/cramino/meta.yml b/modules/nf-core/cramino/meta.yml index aacbf65..671213d 100644 --- a/modules/nf-core/cramino/meta.yml +++ b/modules/nf-core/cramino/meta.yml @@ -26,4 +26,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/fastqc/meta.yml b/modules/nf-core/fastqc/meta.yml index 24b874f..bdd9dae 100644 --- a/modules/nf-core/fastqc/meta.yml +++ b/modules/nf-core/fastqc/meta.yml @@ -25,4 +25,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/filtlong/meta.yml b/modules/nf-core/filtlong/meta.yml index 57da094..0c17345 100644 --- a/modules/nf-core/filtlong/meta.yml +++ b/modules/nf-core/filtlong/meta.yml @@ -26,4 +26,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/flye/meta.yml b/modules/nf-core/flye/meta.yml index 0543554..db2013e 100644 --- a/modules/nf-core/flye/meta.yml +++ b/modules/nf-core/flye/meta.yml @@ -30,4 +30,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/medaka/meta.yml b/modules/nf-core/medaka/meta.yml index eb4d42e..b5dc0d9 100644 --- a/modules/nf-core/medaka/meta.yml +++ b/modules/nf-core/medaka/meta.yml @@ -29,4 +29,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/minimap2/meta.yml b/modules/nf-core/minimap2/meta.yml index e7c0a30..bbf6b1a 100644 --- a/modules/nf-core/minimap2/meta.yml +++ b/modules/nf-core/minimap2/meta.yml @@ -28,4 +28,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/modkit/meta.yml b/modules/nf-core/modkit/meta.yml index 8d1acb9..7fd8c29 100644 --- a/modules/nf-core/modkit/meta.yml +++ b/modules/nf-core/modkit/meta.yml @@ -32,4 +32,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/multiqc/meta.yml b/modules/nf-core/multiqc/meta.yml index 879d150..15ab2ed 100644 --- a/modules/nf-core/multiqc/meta.yml +++ b/modules/nf-core/multiqc/meta.yml @@ -29,4 +29,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/nanoplot/meta.yml b/modules/nf-core/nanoplot/meta.yml index cdf3dde..0802694 100644 --- a/modules/nf-core/nanoplot/meta.yml +++ b/modules/nf-core/nanoplot/meta.yml @@ -27,4 +27,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/pod5/meta.yml b/modules/nf-core/pod5/meta.yml index d2255a8..5988e26 100644 --- a/modules/nf-core/pod5/meta.yml +++ b/modules/nf-core/pod5/meta.yml @@ -25,4 +25,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/quast/meta.yml b/modules/nf-core/quast/meta.yml index 0303a7c..03b2147 100644 --- a/modules/nf-core/quast/meta.yml +++ b/modules/nf-core/quast/meta.yml @@ -25,4 +25,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/samtools/meta.yml b/modules/nf-core/samtools/meta.yml index 31aeb17..212d30a 100644 --- a/modules/nf-core/samtools/meta.yml +++ b/modules/nf-core/samtools/meta.yml @@ -11,4 +11,4 @@ tools: description: Tools for manipulating next-generation sequencing data. homepage: http://www.htslib.org/ authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/sniffles/meta.yml b/modules/nf-core/sniffles/meta.yml index 54fd4d4..460f7dc 100644 --- a/modules/nf-core/sniffles/meta.yml +++ b/modules/nf-core/sniffles/meta.yml @@ -32,4 +32,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/snpeff/meta.yml b/modules/nf-core/snpeff/meta.yml index e29a157..1cc1f1f 100644 --- a/modules/nf-core/snpeff/meta.yml +++ b/modules/nf-core/snpeff/meta.yml @@ -9,4 +9,4 @@ tools: description: Genetic variant annotation and effect prediction toolbox. homepage: https://pcingola.github.io/SnpEff/ authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/vep/meta.yml b/modules/nf-core/vep/meta.yml index 1a6b573..9be6389 100644 --- a/modules/nf-core/vep/meta.yml +++ b/modules/nf-core/vep/meta.yml @@ -9,4 +9,4 @@ tools: description: Predict the functional consequences of genomic variants. homepage: https://www.ensembl.org/info/docs/tools/vep/index.html authors: - - "@jyoda68" + - "@JD2112" diff --git a/modules/nf-core/whatshap/meta.yml b/modules/nf-core/whatshap/meta.yml index 38a1447..0d686e6 100644 --- a/modules/nf-core/whatshap/meta.yml +++ b/modules/nf-core/whatshap/meta.yml @@ -10,4 +10,4 @@ tools: description: Software for phasing genomic variants using DNA sequencing reads. homepage: https://whatshap.readthedocs.io/ authors: - - "@jyoda68" + - "@JD2112" diff --git a/samplesheet.csv b/samplesheet.csv index 1dac6a2..4c54b0d 100644 --- a/samplesheet.csv +++ b/samplesheet.csv @@ -1,9 +1,2 @@ run_id,input_path,input_type,kit,model,modified_bases_model -sample_bc03,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode03,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 -sample_bc05,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode05,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 -sample_bc06,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode06,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 -sample_bc11,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode11,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 -sample_bc12,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode12,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 -sample_bc14,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode14,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 -sample_bc15,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode15,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 -sample_bc17,/data/Jyotirmoy/CF-Projects/Internal/CFFMHS-PS-6965/nextflow/bam_pass/barcode17,bam,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_hac@v4.3.0,dna_r10.4.1_e8.2_400bps_hac@v4.3.0_5mCG_5hmCG@v1 \ No newline at end of file +SAMPLE_ID,/data/Jyotirmoy//,pod5,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_sup@v5.2.0,dna_r10.4.1_e8.2_400bps_sup@v5.2.0_5mCG_5hmCG@v2 \ No newline at end of file diff --git a/subworkflows/local/basecall_demux/meta.yml b/subworkflows/local/basecall_demux/meta.yml index 1cd809e..11652a6 100644 --- a/subworkflows/local/basecall_demux/meta.yml +++ b/subworkflows/local/basecall_demux/meta.yml @@ -17,4 +17,4 @@ output: type: file description: File containing software versions authors: - - "@jyoda68" + - "@JD2112" From 6461f0b2451ac81a506fd2373a860b164cc51680 Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Fri, 30 Oct 2026 14:45:44 +0100 Subject: [PATCH 12/13] (feat): security audit and vulnerabilities checked --- .github/workflows/ci.yml | 41 +- .gitignore | 19 +- .pre-commit-config.yaml | 16 + README.md | 73 +++- assets/multiqc_config.yml | 7 + bin/security_audit.py | 407 ++++++++++++++++++ conf/eukaryote.config | 8 +- conf/fraka.config | 2 +- conf/human.config | 2 +- conf/modules.config | 61 ++- conf/params.config | 12 +- conf/tests/test_giab.config | 41 ++ conf/version_lock.config | 13 +- docs/validation.md | 79 ++++ lib/Utils.groovy | 163 ++++++- modules/local/execution_status_matrix/main.nf | 111 +++++ modules/nf-core/clair3/environment.yml | 7 + modules/nf-core/clair3/main.nf | 51 +++ modules/nf-core/deepvariant/environment.yml | 7 + modules/nf-core/deepvariant/main.nf | 50 +++ modules/nf-core/happy/environment.yml | 8 + modules/nf-core/happy/main.nf | 85 ++++ modules/nf-core/hiphase/main.nf | 36 +- modules/nf-core/minimap2/main.nf | 2 +- modules/nf-core/pbcpgtools/environment.yml | 7 + modules/nf-core/pbcpgtools/main.nf | 43 ++ modules/nf-core/samtools/faidx/main.nf | 34 ++ modules/nf-core/samtools/index/main.nf | 35 ++ modules/nf-core/sniffles/main.nf | 14 +- modules/nf-core/truvari/environment.yml | 7 + modules/nf-core/truvari/main.nf | 102 +++++ modules/nf-core/whatshap/stats/main.nf | 41 ++ nextflow.config | 4 + nextflow_schema.json | 33 +- samplesheet.csv | 2 +- security_check.sh | 39 ++ subworkflows/local/alignment/main.nf | 27 +- subworkflows/local/benchmarking/main.nf | 70 +++ subworkflows/local/governance/main.nf | 215 +++++++-- subworkflows/local/methylation/main.nf | 70 ++- subworkflows/local/phasing/main.nf | 39 +- subworkflows/local/reporting/main.nf | 21 +- .../local/structural_variants/main.nf | 9 +- subworkflows/local/variant_calling/main.nf | 66 +-- tests/data/giab/hg002_benchmark_regions.bed | 1 + .../data/giab/hg002_benchmark_sv_regions.bed | 1 + tests/data/giab/hg002_truth_smallvar.vcf.gz | Bin 0 -> 206 bytes .../data/giab/hg002_truth_smallvar.vcf.gz.tbi | 0 tests/data/giab/hg002_truth_sv.vcf.gz | Bin 0 -> 305 bytes tests/data/giab/hg002_truth_sv.vcf.gz.tbi | 0 tests/data/ref_nuc.fasta | 2 + tests/data/ref_nuc.fasta.fai | 1 + tests/pipeline/negative_tests.nf.test | 37 ++ tests/run_negative_tests.sh | 91 ++++ tests/unit_tests/test_pipeline_unit.py | 98 +++-- workflows/longvarmeth.nf | 148 +++++-- workflows/nanopore.nf | 147 +++++-- workflows/pacbio.nf | 139 ++++-- 58 files changed, 2529 insertions(+), 315 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100755 bin/security_audit.py create mode 100644 conf/tests/test_giab.config create mode 100644 docs/validation.md create mode 100644 modules/local/execution_status_matrix/main.nf create mode 100644 modules/nf-core/clair3/environment.yml create mode 100644 modules/nf-core/clair3/main.nf create mode 100644 modules/nf-core/deepvariant/environment.yml create mode 100644 modules/nf-core/deepvariant/main.nf create mode 100644 modules/nf-core/happy/environment.yml create mode 100644 modules/nf-core/happy/main.nf create mode 100644 modules/nf-core/pbcpgtools/environment.yml create mode 100644 modules/nf-core/pbcpgtools/main.nf create mode 100644 modules/nf-core/samtools/faidx/main.nf create mode 100644 modules/nf-core/samtools/index/main.nf create mode 100644 modules/nf-core/truvari/environment.yml create mode 100644 modules/nf-core/truvari/main.nf create mode 100644 modules/nf-core/whatshap/stats/main.nf create mode 100755 security_check.sh create mode 100644 subworkflows/local/benchmarking/main.nf create mode 100644 tests/data/giab/hg002_benchmark_regions.bed create mode 100644 tests/data/giab/hg002_benchmark_sv_regions.bed create mode 100644 tests/data/giab/hg002_truth_smallvar.vcf.gz create mode 100644 tests/data/giab/hg002_truth_smallvar.vcf.gz.tbi create mode 100644 tests/data/giab/hg002_truth_sv.vcf.gz create mode 100644 tests/data/giab/hg002_truth_sv.vcf.gz.tbi create mode 100644 tests/data/ref_nuc.fasta.fai create mode 100644 tests/pipeline/negative_tests.nf.test create mode 100755 tests/run_negative_tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97c0bac..5a7c3ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,27 @@ on: workflow_dispatch: jobs: + security_audit: + name: 'Security, Secrets & PHI Audit' + runs-on: ubuntu-latest + steps: + - name: Check out pipeline code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Run Pre-Commit Security Scanner + run: | + chmod +x security_check.sh bin/security_audit.py + ./security_check.sh --strict + pytest: name: 'Python Unit & Integrity Tests' runs-on: ubuntu-latest + needs: security_audit steps: - name: Check out pipeline code uses: actions/checkout@v4 @@ -30,13 +48,31 @@ jobs: pip install pytest pytest tests/unit_tests/ -v + negative_tests: + name: 'Fail-Fast Negative Tests' + runs-on: ubuntu-latest + needs: security_audit + steps: + - name: Check out pipeline code + uses: actions/checkout@v4 + + - name: Set up Nextflow + uses: nf-core/setup-nextflow@v2 + with: + version: 'latest' + + - name: Run negative regression tests + run: | + chmod +x tests/run_negative_tests.sh + ./tests/run_negative_tests.sh + pipeline_stub: name: 'Nextflow Pipeline Stub Tests' runs-on: ubuntu-latest - needs: pytest + needs: [pytest, negative_tests] strategy: matrix: - profile: ['test_ont', 'test_pacbio'] + profile: ['test_ont', 'test_pacbio', 'test_giab'] steps: - name: Check out pipeline code uses: actions/checkout@v4 @@ -50,3 +86,4 @@ jobs: run: | nextflow run main.nf -profile ${{ matrix.profile }} -stub + diff --git a/.gitignore b/.gitignore index 5ea1f0b..8216983 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,25 @@ db/ *.tar.gz *.fa.fai *.fasta.fai +!tests/data/**/*.fai notes.md debugging.md quindecagon_test.md UPDATES.md -TODO.md \ No newline at end of file +TODO.md +review/ + +# Credentials, Tokens & Security Secrets +*.pem +*.key +*.p12 +*.pfx +*.pkcs12 +id_rsa* +id_ed25519* +.env +.env.* +!.env.example +credentials* +*.secret +ROADMAP* \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..74b9097 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +# .pre-commit-config.yaml +# LongVarMeth Git Pre-Commit Hook Configuration +# Automatically runs security, credential, and PHI audit before every commit. +# Install: +# pip install pre-commit && pre-commit install + +repos: + - repo: local + hooks: + - id: security-audit + name: LongVarMeth Security, Secret & PHI Audit + entry: ./security_check.sh --staged-only --strict + language: script + pass_filenames: false + always_run: true + diff --git a/README.md b/README.md index 81329aa..ab154d0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,11 @@ [![code style](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/JD2112/longvarmeth/badges/black.json)](https://github.com/psf/black) [![flake8](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/JD2112/longvarmeth/badges/flake8.json)](https://flake8.pycqa.org) -A modular, clinical-grade Nextflow DSL2 platform for single-molecule long-read genomics and epigenomics (**Oxford Nanopore & PacBio HiFi**). It features an explicit 12-stage analytical architecture with host GPU-accelerated basecalling/demux, pre- and post-alignment QC, *de novo* assembly & polishing, kinetic tag preservation (`MM`/`ML`), small and structural variant calling (SNV/InDel/SV/CNV/STR), haplotype co-phasing, strict clinical governance & PHI sanitization (inspired by `milou`), and interactive reporting. +A modular Nextflow DSL2 evaluation and research platform with forensic clinical governance controls for single-molecule long-read genomics and epigenomics (**Oxford Nanopore & PacBio HiFi**). It features an explicit 12-stage analytical architecture with host GPU-accelerated basecalling/demux, pre- and post-alignment QC, *de novo* assembly & polishing, native kinetic tag streaming (`MM`/`ML`), modular small variant calling (`DeepVariant`, `Clair3`, `BCFtools`), structural variant calling (`Sniffles2`, `TRGT`, `CNVkit`), joint multi-variant haplotype co-phasing (`HiPhase`, `WhatsHap`), automated truth-set benchmarking (`hap.py`, `Truvari`), full-chain PHI de-identification and SHA-256 provenance manifests (inspired by `milou`), and a transparent MultiQC execution status matrix. + +> [!IMPORTANT] +> **Intended Use & Regulatory Statement**: +> LongVarMeth is designed for **Research Use Only (RUO)** and clinical evaluation. It is not an FDA-cleared or CE-IVD diagnostic device. While the architecture enforces strict clinical governance mechanisms (PHI scrub, deterministic anonymization, cryptographic audit trail), clinical laboratories must establish analytical validity independently in compliance with local regulatory standards (CAP/CLIA, ISO 15189) before reporting clinical findings. See [docs/validation.md](docs/validation.md). ## Pipeline Architecture @@ -18,19 +22,18 @@ A modular, clinical-grade Nextflow DSL2 platform for single-molecule long-read g | Stage | Oxford Nanopore
(`--platform ont`) | PacBio HiFi
(`--platform pacbio`) | Inputs $\rightarrow$ Outputs | | :--- | :--- | :--- | :--- | -| **Input Checksum** | Ingestion SHA-256 verification | Ingestion SHA-256 verification | Raw files $\rightarrow$
Audit manifest | -| **Read QC** | `FastQC` + `NanoPlot`
`Cramino` + `samtools` | `Cramino` + `FastQC`
`NanoPlot` | Reads / BAM $\rightarrow$
Yield & Q-scores | -| **Basecalling**
*(ONT raw)* | Host GPU `Dorado`
+ `pod5 convert` demux | *On-instrument demux*
(Revio / Sequel IIe) | `pod5` / `fast5` $\rightarrow$
Basecalled BAM | -| **Assembly**
*(de novo)* | `Flye --nano-hq`
+ `Medaka` polishing | `Hifiasm` (diploid)
+ `Racon` polishing | Long reads $\rightarrow$
Assembly FASTA + BUSCO | -| **Alignment** | `minimap2 -ax map-ont`
+ `MM`/`ML` tag restoration | `pbmm2 align --preset HIFI`
*(native kinetic preservation)* | Reads + Ref $\rightarrow$
Sorted BAM (`MM`/`ML`) | -| **Small Variants** | `Clair3` (ONT model)
`DeepVariant` / `BCFtools` | `DeepVariant` (HiFi model)
`Clair3` / `BCFtools` | Sorted BAM $\rightarrow$
SNV / InDel VCF | -| **SVs, CNVs & STRs** | `Sniffles2` (SVs)
`CNVkit` (CNVs) | `Sniffles2` (SVs) + `HiFiCNV`
`TRGT` (tandem repeats) | Sorted BAM $\rightarrow$
SV, CNV & STR calls | -| **Epigenomics** | `modkit pileup`
(5mC / 5hmC / 6mA motifs) | `pbcpgtools` + `methbat`
+ `modkit pileup` | Sorted BAM $\rightarrow$
BedMethyl & CpG scores | -| **Phasing** | `WhatsHap phase`
+ `haplotag` (`HP:i:1/2`) | `HiPhase`
*(co-phases SNV + SV + STR)* | VCFs + BAM $\rightarrow$
Phased VCF & Haplotagged BAM | -| **Annotation** | `Ensembl VEP`
`SnpEff` + ClinVar | `Ensembl VEP`
`SnpEff` + ClinVar | Phased VCF $\rightarrow$
Annotated clinical VCF | -| **Governance** | `SANITIZE_METADATA` (PHI scrub)
+ cryptographic SHA-256 manifest | `SANITIZE_METADATA` (PHI scrub)
+ cryptographic SHA-256 manifest | Artifacts $\rightarrow$
Sanitized BAM & manifest | -| **Visualization** | `JBrowse 2` web workspace
`Methylartist` locus plots
Interactive Methylation Dashboard | `JBrowse 2` web workspace
`Methylartist` locus plots
Interactive Methylation Dashboard | BAMs, VCFs $\rightarrow$
Web workspace & HTML reports | -| **Reporting** | `MultiQC` consolidated dashboard
+ Executive clinical summary | `MultiQC` consolidated dashboard
+ Executive clinical summary | Metrics $\rightarrow$
MultiQC HTML report | +| **1. Input Checksum** | Ingestion SHA-256 verification | Ingestion SHA-256 verification | Raw files $\rightarrow$ Audit manifest | +| **2. Basecalling & Demux**
*(ONT raw)* | Host GPU `Dorado`
+ `pod5 convert` demux | *On-instrument demux*
(Revio / Sequel IIe) | `pod5` / `fast5` $\rightarrow$ Basecalled BAM | +| **3. Read QC & Trimming** | `FastQC` + `NanoPlot`
`Cramino` + `Filtlong` | `Cramino` + `FastQC`
`NanoPlot` | Reads / BAM $\rightarrow$ Yield & Q-scores | +| **4. Alignment & Tag Streaming** | `minimap2 -ax map-ont -y`
*(streaming MM/ML tag preservation)* | `pbmm2 align --preset HIFI`
*(native kinetic preservation)* | Reads + Ref $\rightarrow$ Sorted BAM (`MM`/`ML`) | +| **5. Mod Tag Verification** | `CHECK_MOD_TAGS`
*(samtools view aux check)* | `CHECK_MOD_TAGS`
*(kinetics validation)* | Sorted BAM $\rightarrow$ Kinetic Tag Audit Log | +| **6. Small Variants** | `DeepVariant` (`ONT_R104`)
`Clair3` / `BCFtools` | `DeepVariant` (`PACBIO`)
`Clair3` / `BCFtools` | Sorted BAM $\rightarrow$ SNV / InDel VCF | +| **7. SVs, CNVs & STRs** | `Sniffles2` (SVs)
`CNVkit` (CNVs) | `Sniffles2` (SVs) + `HiFiCNV`
`TRGT` (tandem repeats) | Sorted BAM $\rightarrow$ SV, CNV & STR calls | +| **8. Epigenomics** | `modkit pileup`
(5mC / 5hmC / 6mA motifs) | `pbcpgtools` (`aligned_bam_to_cpg_scores`)
+ `methbat` | Sorted BAM $\rightarrow$ BedMethyl & CpG scores | +| **9. Joint Co-Phasing** | `WhatsHap phase`
+ `haplotag` (`HP:i:1/2`) | `HiPhase`
*(joint SNV + SV + STR co-phasing)* | VCFs + BAM $\rightarrow$ Phased VCF & Haplotagged BAM | +| **10. Truth-Set Benchmark** | `hap.py` (SNVs/Indels)
+ `Truvari` (SVs) | `hap.py` (SNVs/Indels)
+ `Truvari` (SVs) | VCFs + GIAB HG002 $\rightarrow$ Precision/Recall/F1 TSVs | +| **11. Clinical Governance** | `SANITIZE_BAM` + `SANITIZE_VCF`
+ cryptographic SHA-256 manifest | `SANITIZE_BAM` + `SANITIZE_VCF`
+ cryptographic SHA-256 manifest | Artifacts $\rightarrow$ Sanitized BAM/VCF & manifest.json | +| **12. Visualization & Reporting**| `JBrowse 2` + `Methylartist`
`MultiQC` Execution Status Matrix | `JBrowse 2` + `Methylation Dashboard`
`MultiQC` Execution Status Matrix | BAMs, VCFs $\rightarrow$ MultiQC Report & Tracks | ### Multi-Organism Tailoring Matrix @@ -111,7 +114,47 @@ You can configure execution parameters from the command line or via custom confi | `--enable_vep` | boolean | `false` | Enable Ensembl VEP functional variant annotation | | `--enable_snpeff` | boolean | `false` | Enable SnpEff variant annotation | | `--enable_methylation_dashboard` | boolean | `false` (auto in `human`) | Generate interactive AG-Grid & IGV-Web clinical methylation report (adapted from `gms-nallo`) | -| `--dorado_bin` | string | `/path/to/dorado`| Host path to Dorado binary for host GPU-accelerated basecalling | +| `--run_benchmarking` | boolean | `false` | Enable empirical truth-set concordance evaluation (`hap.py` + `Truvari`) | +| `--benchmark_truth_vcf` | string | `""` | Path to NIST GIAB HG002 small variant truth VCF | +| `--benchmark_truth_bed` | string | `""` | Path to NIST GIAB HG002 small variant benchmark region BED | +| `--benchmark_sv_truth_vcf` | string | `""` | Path to NIST GIAB HG002 structural variant truth VCF | +| `--benchmark_sv_truth_bed` | string | `""` | Path to NIST GIAB HG002 structural variant benchmark region BED | +| `--dorado_bin` | string | `dorado` | Host path to Dorado binary for host GPU-accelerated basecalling | + +--- + +## Biological Scope & Technology Limitations + +Understanding the physical and algorithmic boundaries of single-molecule sequencing is critical for valid result interpretation: + +1. **Oxford Nanopore R10.4.1 Chemistry**: + - **Simplex vs Duplex**: Simplex R10.4.1 reads achieve modal Q20+ raw accuracy. However, small insertion/deletion (indel) errors persist in homopolymer tracts ($N \ge 6$) due to ionic current dwell-time saturation. Indel findings in homopolymers should be interpreted conservatively. + - **Base Modifications**: Oxford Nanopore chemistry natively distinguishes 5mC, 5hmC, and unmethylated cytosines using `modkit pileup`. High-confidence quantification requires $\ge 15\times$ read depth per CpG site. +2. **PacBio HiFi Chemistry (Revio / Sequel IIe)**: + - **Consensus Accuracy**: HiFi circular consensus sequencing achieves Q30+ base quality, yielding exceptional SNV and indel accuracy ($F_1 > 0.99$). + - **Kinetics**: 5mC detection relies on inter-pulse duration (IPD) kinetics aggregated via `pbcpgtools` (`aligned_bam_to_cpg_scores`). BAM inputs must preserve kinetics tags (`MM`, `ML` or pulse tags). +3. **Joint Haplotype Co-Phasing (`HiPhase` / `WhatsHap`)**: + - **Phase Block Continuity**: Long-range phase blocks depend on read length N50 and sample heterozygosity. In extended runs of homozygosity (ROH) or low-complexity segmental duplications, phase blocks break deterministically. + - **Multi-Variant Resolution**: HiPhase jointly co-phases small variants, SVs (`Sniffles2`), and STR repeat expansions (`TRGT`) into unified phase sets (`PS`). + +--- + +## Truth-Set Benchmarking & MultiQC Execution Matrix + +LongVarMeth introduces automated truth-set benchmarking using the NIST / GIAB HG002 reference standard: + +```bash +# Run automated GIAB benchmark check in stub mode: +nextflow run main.nf -profile test_giab -stub +``` + +The resulting `multiqc_report.html` features: +- **Pipeline Execution Status Matrix**: Explicit table classifying each of the 12 pipeline stages as `COMPLETED`, `SKIPPED (Pre-aligned Input)`, `SKIPPED (Parameter-Disabled)`, or `FAILED`, ensuring incomplete runs are never mistaken for completed analyses. +- **Hap.py Concordance Table**: Precision, recall, and F1 scores stratified by variant class. +- **Truvari SV Benchmark Table**: TP, FP, FN, recall, and F1 scores for structural variants $\ge 50$ bp. +- **Cryptographic Audit Manifest**: Full-chain SHA-256 provenance tracking. + +For full validation procedures and benchmark calibrations, see **[docs/validation.md](docs/validation.md)**. diff --git a/assets/multiqc_config.yml b/assets/multiqc_config.yml index 218af82..238f2aa 100755 --- a/assets/multiqc_config.yml +++ b/assets/multiqc_config.yml @@ -7,6 +7,8 @@ report_comment: > This report has been generated by the Nextflow DSL2 longvarmeth pipeline. report_section_order: + "pipeline_execution_status": + order: -1003 "pipeline-summary": order: -1002 "software-versions": @@ -15,6 +17,11 @@ report_section_order: order: -1000 custom_data: + pipeline_execution_status: + file_format: "tsv" + section_name: "Pipeline Execution Status Matrix" + description: "Transparent execution status audit across all 12 analytical stages." + plot_type: "table" pipeline-summary: file_format: "yaml" section_name: "Workflow Summary" diff --git a/bin/security_audit.py b/bin/security_audit.py new file mode 100755 index 0000000..406c873 --- /dev/null +++ b/bin/security_audit.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +LongVarMeth Pre-Commit & Pre-Push Security Audit Scanner +Author: Jyotirmoy Das / LongVarMeth Core Team +Version: 1.0.0 + +Comprehensive defensive security scanner for: +- API keys, credentials, tokens, and private cryptographic keys +- Personal Health Information (PHI) & Personally Identifiable Information (PII) +- Hardcoded local system paths and internal infrastructure IP addresses +- Sensitive / unignored binary artifacts and oversized files (>25MB) +- Shell and code injection risks (e.g. unsafe eval) + +Usage: + python3 bin/security_audit.py [--all | --staged-only] [--strict] [--json] +""" + +import os +import sys +import re +import json +import subprocess +import argparse +from pathlib import Path + +# ANSI Color codes for readable terminal feedback +RED = "\033[91m" +YELLOW = "\033[93m" +GREEN = "\033[92m" +BLUE = "\033[94m" +CYAN = "\033[96m" +BOLD = "\033[1m" +RESET = "\033[0m" + +# Maximum allowable file size for code/text files in repo (25 MB) +MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 + +# Files & directories strictly excluded from the scan +EXCLUDE_DIRS = { + ".git", + "work", + "results", + "results_*", + ".nextflow", + ".pytest_cache", + "__pycache__", + "venv", + ".venv", + "review", # Local reviewer notes + "node_modules", +} + +# File extensions to skip (binary formats) +SKIP_EXTENSIONS = { + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", + ".gz", ".tar", ".zip", ".bam", ".bai", ".cram", ".crai", + ".tbi", ".csi", ".sif", ".img", ".pyc" +} + +# ============================================================================== +# AUDIT PATTERNS (Update this dictionary whenever new security checks are added) +# ============================================================================== +AUDIT_PATTERNS = [ + # --- 1. Cloud & Platform Secrets --- + { + "id": "SEC-001", + "category": "Secret", + "name": "AWS Access Key ID", + "regex": re.compile(r"\b(AKIA[0-9A-Z]{16})\b"), + "severity": "CRITICAL" + }, + { + "id": "SEC-002", + "category": "Secret", + "name": "Google Cloud API Key", + "regex": re.compile(r"\b(AIza[0-9A-Za-z\-_]{35})\b"), + "severity": "CRITICAL" + }, + { + "id": "SEC-003", + "category": "Secret", + "name": "GitHub Personal Access Token", + "regex": re.compile(r"\b(gh[pousr]_[A-Za-z0-9_]{36,255}|github_pat_[A-Za-z0-9_]{82})\b"), + "severity": "CRITICAL" + }, + { + "id": "SEC-004", + "category": "Secret", + "name": "GitLab Personal Access Token", + "regex": re.compile(r"\b(glpat-[0-9a-zA-Z\-_]{20,})\b"), + "severity": "CRITICAL" + }, + { + "id": "SEC-005", + "category": "Secret", + "name": "Slack Token", + "regex": re.compile(r"\b(xox[baprs]-[0-9a-zA-Z]{10,48})\b"), + "severity": "CRITICAL" + }, + { + "id": "SEC-006", + "category": "Secret", + "name": "Private Cryptographic Key", + "regex": re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"), + "severity": "CRITICAL" + }, + { + "id": "SEC-007", + "category": "Secret", + "name": "OpenAI / Anthropic API Key", + "regex": re.compile(r"\b(sk-[a-zA-Z0-9]{20,})\b"), + "severity": "CRITICAL" + }, + { + "id": "SEC-008", + "category": "Secret", + "name": "HuggingFace Access Token", + "regex": re.compile(r"\b(hf_[a-zA-Z0-9]{34,})\b"), + "severity": "CRITICAL" + }, + { + "id": "SEC-009", + "category": "Secret", + "name": "Hardcoded Password / Auth Secret Assignment", + "regex": re.compile(r'''(?i)(?:password|passwd|api_key|secret_key|auth_token)\s*[:=]\s*["']([^"'\s]{8,})["']'''), + "severity": "HIGH", + "allowlist_values": {"password", "secret", "token", "default", "none", "password123"} + }, + + # --- 2. Environment & Infrastructure Leakage --- + { + "id": "INF-001", + "category": "Environment", + "name": "Hardcoded User Filesystem Path", + "regex": re.compile(r'''(?:['"]|^|\s)(/Users/[a-zA-Z0-9_\-]+|/home/[a-zA-Z0-9_\-]+|/data/[A-Z][a-zA-Z0-9_\-]+)(?:/|\b)'''), + "severity": "HIGH", + # Allow paths in documentation examples or audit logs if specified + "file_allowlist": {"ROADMAP_RESOLVE.md", "ROADMAP.md", "bin/security_audit.py"} + }, + { + "id": "INF-002", + "category": "Environment", + "name": "Internal / RFC1918 Private IP Address", + "regex": re.compile(r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b"), + "severity": "MEDIUM", + "file_allowlist": {"bin/security_audit.py"} + }, + + # --- 3. PHI / PII & Clinical Identifiers --- + { + "id": "PHI-001", + "category": "Clinical PHI", + "name": "US Social Security Number Pattern", + "regex": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), + "severity": "HIGH", + "file_allowlist": {"bin/security_audit.py"} + }, + { + "id": "PHI-002", + "category": "Clinical PHI", + "name": "UK NHS Number Pattern", + "regex": re.compile(r"\b\d{3}\s?\d{3}\s?\d{4}\b"), + "severity": "HIGH", + "file_allowlist": {"bin/security_audit.py"} + }, + + # --- 4. Unsafe Code Constructs --- + { + "id": "COD-001", + "category": "Code Safety", + "name": "Unsafe Python eval() on Dynamic Input", + "regex": re.compile(r"\beval\s*\([^\)]+\)"), + "severity": "MEDIUM", + "file_allowlist": {"bin/security_audit.py"} + } +] + +# Sensitive file names that should never be committed +PROHIBITED_FILE_PATTERNS = [ + re.compile(r"^\.env.*$"), + re.compile(r"^.*\.pem$"), + re.compile(r"^.*\.key$"), + re.compile(r"^.*\.p12$"), + re.compile(r"^.*\.pfx$"), + re.compile(r"^id_rsa.*$"), + re.compile(r"^id_ed25519.*$"), + re.compile(r"^credentials$"), + re.compile(r"^.*\.secret$") +] + + +def get_git_root() -> Path: + """Resolve repository root using git.""" + try: + out = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True) + return Path(out.strip()) + except Exception: + return Path(__file__).resolve().parent.parent + + +def get_staged_files(repo_root: Path) -> list: + """Retrieve list of files staged in git.""" + try: + out = subprocess.check_output(["git", "diff", "--cached", "--name-only"], text=True) + lines = [repo_root / line.strip() for line in out.splitlines() if line.strip()] + return [p for p in lines if p.exists() and p.is_file()] + except Exception: + return [] + + +def get_all_scannable_files(repo_root: Path) -> list: + """Discover all non-ignored, non-binary repository files using git ls-files.""" + try: + out = subprocess.check_output( + ["git", "ls-files", "-co", "--exclude-standard"], + cwd=str(repo_root), + text=True + ) + files = [] + for line in out.splitlines(): + line = line.strip() + if not line: + continue + path = repo_root / line + if path.exists() and path.is_file(): + files.append(path) + return files + except Exception: + # Fallback to filesystem walk if git is unavailable + scannable = [] + for root, dirs, files in os.walk(repo_root): + dirs[:] = [ + d for d in dirs + if d not in EXCLUDE_DIRS and not any(d.startswith(prefix) for prefix in ["results_", "work_"]) + ] + for file in files: + path = Path(root) / file + scannable.append(path) + return scannable + + +def audit_repository(repo_root: Path, staged_only: bool = False) -> dict: + """Executes full security audit on repository files.""" + files_to_scan = get_staged_files(repo_root) if staged_only else get_all_scannable_files(repo_root) + findings = [] + prohibited_files_found = [] + oversized_files_found = [] + + for file_path in files_to_scan: + rel_path = file_path.relative_to(repo_root).as_posix() + + # 1. Check Prohibited Filenames + for p_pat in PROHIBITED_FILE_PATTERNS: + if p_pat.match(file_path.name): + # Check if ignored by git + try: + subprocess.check_call(["git", "check-ignore", "-q", str(file_path)]) + is_ignored = True + except subprocess.CalledProcessError: + is_ignored = False + + if not is_ignored: + prohibited_files_found.append({ + "file": rel_path, + "rule": "Sensitive / Credential Filename", + "severity": "CRITICAL" + }) + + # 2. Check File Size + if file_path.exists(): + try: + size = file_path.stat().st_size + if size > MAX_FILE_SIZE_BYTES and not rel_path.startswith(".git"): + oversized_files_found.append({ + "file": rel_path, + "size_mb": round(size / (1024 * 1024), 2), + "severity": "HIGH" + }) + except Exception: + pass + + # 3. Skip binaries from content regex scanning + if file_path.suffix.lower() in SKIP_EXTENSIONS or file_path.name.endswith(".tbi"): + continue + + if not file_path.exists() or not file_path.is_file(): + continue + + # 4. Scan Text Content Line by Line + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + for line_idx, line in enumerate(f, 1): + for rule in AUDIT_PATTERNS: + # Check file allowlist + allowlisted = rule.get("file_allowlist", set()) + if any(rel_path.endswith(allowed) or rel_path == allowed for allowed in allowlisted): + continue + + match = rule["regex"].search(line) + if match: + matched_str = match.group(0).strip() + + # Check rule-specific value allowlist + val_allowlist = rule.get("allowlist_values", set()) + if matched_str.lower() in val_allowlist: + continue + + # Mask sensitive match for display + if rule["category"] == "Secret" and len(matched_str) > 8: + masked = matched_str[:4] + "*" * (len(matched_str) - 8) + matched_str[-4:] + else: + masked = matched_str + + findings.append({ + "rule_id": rule["id"], + "category": rule["category"], + "name": rule["name"], + "severity": rule["severity"], + "file": rel_path, + "line": line_idx, + "match": masked, + "context": line.strip()[:120] + }) + except Exception: + pass + + return { + "scanned_files_count": len(files_to_scan), + "findings": findings, + "prohibited_files": prohibited_files_found, + "oversized_files": oversized_files_found + } + + +def main(): + parser = argparse.ArgumentParser(description="LongVarMeth Security & Pre-Push Audit Scanner") + parser.add_argument("--staged-only", action="store_true", help="Scan only git staged files") + parser.add_argument("--strict", action="store_true", help="Fail with exit code 1 on WARNINGS or HIGH severity") + parser.add_argument("--json", action="store_true", help="Output results as JSON") + args = parser.parse_args() + + repo_root = get_git_root() + + if not args.json: + print(f"\n{BOLD}{CYAN}{'='*70}{RESET}") + print(f"{BOLD}{CYAN} LongVarMeth Pre-Push Security & Compliance Audit Scanner {RESET}") + print(f"{BOLD}{CYAN}{'='*70}{RESET}") + print(f"Repository Root : {repo_root}") + print(f"Scan Mode : {'Staged files only' if args.staged_only else 'Full workspace audit'}\n") + + results = audit_repository(repo_root, staged_only=args.staged_only) + + if args.json: + print(json.dumps(results, indent=2)) + sys.exit(1 if results["findings"] or results["prohibited_files"] else 0) + + # Print Findings + findings = results["findings"] + prohibited = results["prohibited_files"] + oversized = results["oversized_files"] + + critical_count = sum(1 for f in findings if f["severity"] == "CRITICAL") + len(prohibited) + high_count = sum(1 for f in findings if f["severity"] == "HIGH") + len(oversized) + medium_count = sum(1 for f in findings if f["severity"] == "MEDIUM") + + # 1. Prohibited File Violations + if prohibited: + print(f"{BOLD}{RED}[!] PROHIBITED / SENSITIVE FILES DETECTED:{RESET}") + for p in prohibited: + print(f" {RED}✖ [{p['severity']}] {p['file']} -> {p['rule']}{RESET}") + print() + + # 2. Oversized Files + if oversized: + print(f"{BOLD}{YELLOW}[!] OVERSIZED FILES DETECTED (>25MB):{RESET}") + for o in oversized: + print(f" {YELLOW}⚠ [{o['severity']}] {o['file']} ({o['size_mb']} MB){RESET}") + print() + + # 3. Content Findings + if findings: + print(f"{BOLD}{RED}[!] SECURITY ISSUES IDENTIFIED IN SOURCE CODE:{RESET}") + for f in findings: + color = RED if f["severity"] in ["CRITICAL", "HIGH"] else YELLOW + print(f" {color}✖ [{f['severity']}] {f['rule_id']}: {f['name']}{RESET}") + print(f" File: {BOLD}{f['file']}:{f['line']}{RESET}") + print(f" Match: {f['match']}") + print(f" Code : {f['context']}\n") + + # Summary + print(f"{BOLD}{CYAN}{'-'*70}{RESET}") + print(f"Scanned Files : {results['scanned_files_count']}") + print(f"Critical : {RED if critical_count else GREEN}{critical_count}{RESET}") + print(f"High : {YELLOW if high_count else GREEN}{high_count}{RESET}") + print(f"Medium : {BLUE if medium_count else GREEN}{medium_count}{RESET}") + print(f"{BOLD}{CYAN}{'-'*70}{RESET}") + + if critical_count > 0 or (args.strict and high_count > 0): + print(f"\n{BOLD}{RED}❌ AUDIT FAILED: Security violations must be resolved before pushing to GitHub.{RESET}\n") + sys.exit(1) + else: + print(f"\n{BOLD}{GREEN}✅ AUDIT PASSED: No credentials, secrets, or security violations found. Safe to push.{RESET}\n") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/conf/eukaryote.config b/conf/eukaryote.config index 2d5900a..c79c481 100644 --- a/conf/eukaryote.config +++ b/conf/eukaryote.config @@ -4,10 +4,10 @@ params { species = "eukaryote" run_type = "reference_based" - // Eukaryote references (Artemia default) - ref_nuc = "/data/Jyotirmoy//GCF_032884065.1_ASM3288406v1_genomic.fna" - ref_mt_ap = "/data/Jyotirmoy//OR423215_1.fasta" - ref_mt_au = "/data/Jyotirmoy//MN240408_1.fasta" + // Eukaryote references (specify custom FASTA via CLI or config) + ref_nuc = "" + ref_mt_ap = "" + ref_mt_au = "" // Eukaryote specific variant parameters ploidy = 2 diff --git a/conf/fraka.config b/conf/fraka.config index 781734d..a4ea118 100644 --- a/conf/fraka.config +++ b/conf/fraka.config @@ -9,7 +9,7 @@ params { // Dorado GPU parameters on Fraka host - dorado_bin = '/data/Jyotirmoy/Tools/dorado-2.1.2-linux-x64/bin/dorado' + dorado_bin = 'dorado' // Resolves via PATH or module load dorado gpu_devices = 'cuda:all' threads = 32 diff --git a/conf/human.config b/conf/human.config index 3bf4487..b632245 100644 --- a/conf/human.config +++ b/conf/human.config @@ -20,5 +20,5 @@ params { // Enable variant effect annotation for human runs enable_snpeff = true snpeff_db = "GRCh38.105" - snpeff_cache = "/data/Jyotirmoy/Reference_data/snpEff" + snpeff_cache = "" } diff --git a/conf/modules.config b/conf/modules.config index 59950d0..050f68d 100644 --- a/conf/modules.config +++ b/conf/modules.config @@ -79,23 +79,37 @@ process { ] } - withName: 'TRANSFER_TAGS.*' { + withName: '.*(DEEPVARIANT|CLAIR3|BCFTOOLS_CONCAT).*' { publishDir = [ - path: { "${params.outdir}/alignments/tagged" }, + path: { "${params.outdir}/variants" }, mode: 'copy' ] } - withName: '.*CALL_.*' { + withName: '.*(PBCPGTOOLS|MOD_NUC).*' { publishDir = [ - path: { "${params.outdir}/variants/raw" }, + path: { "${params.outdir}/methylation" }, mode: 'copy' ] } - withName: 'BCFTOOLS_CONCAT' { + withName: 'CHECK_MOD_TAGS' { publishDir = [ - path: { "${params.outdir}/variants" }, + path: { "${params.outdir}/methylation/qc" }, + mode: 'copy' + ] + } + + withName: 'HIPHASE' { + publishDir = [ + path: { "${params.outdir}/variants/phased" }, + mode: 'copy' + ] + } + + withName: 'WHATSHAP_STATS' { + publishDir = [ + path: { "${params.outdir}/variants/phased" }, mode: 'copy' ] } @@ -198,6 +212,20 @@ process { ] } + withName: '.*SANITIZE_BAM' { + publishDir = [ + path: { "${params.outdir}/alignments/sanitized" }, + mode: 'copy' + ] + } + + withName: '.*SANITIZE_VCF' { + publishDir = [ + path: { "${params.outdir}/variants/sanitized" }, + mode: 'copy' + ] + } + withName: '.*GENERATE_AUDIT_MANIFEST' { publishDir = [ path: { "${params.outdir}/audit" }, @@ -211,4 +239,25 @@ process { mode: 'copy' ] } + + withName: 'HAPPY' { + publishDir = [ + path: { "${params.outdir}/benchmarking/happy" }, + mode: 'copy' + ] + } + + withName: 'TRUVARI' { + publishDir = [ + path: { "${params.outdir}/benchmarking/truvari" }, + mode: 'copy' + ] + } + + withName: 'EXECUTION_STATUS_MATRIX' { + publishDir = [ + path: { "${params.outdir}/reports/execution_status" }, + mode: 'copy' + ] + } } diff --git a/conf/params.config b/conf/params.config index 014a3ca..7e2c1ee 100755 --- a/conf/params.config +++ b/conf/params.config @@ -17,6 +17,13 @@ params { // Clinical Governance & PHI Sanitization (JD2112/milou inspired) enable_phi_sanitization = true enable_audit_trail = true + + // Empirical Truth-Set Benchmarking (GIAB HG002) + run_benchmarking = false + benchmark_truth_vcf = "" + benchmark_truth_bed = "" + benchmark_sv_truth_vcf = "" + benchmark_sv_truth_bed = "" // Modular Stage Execution Toggles skip_variants = false // Skip small and structural variant calling @@ -27,8 +34,8 @@ params { enable_trgt = false repeat_bed = "" - // Dorado GPU parameters (executed on host, not containerized) - dorado_bin = "/data/Jyotirmoy/Tools/dorado-2.1.2-linux-x64/bin/dorado" + // Dorado GPU parameters (executed on host or via container) + dorado_bin = "dorado" gpu_devices = "cuda:all" threads = 32 @@ -43,6 +50,7 @@ params { ref_mt_au = "" // Module default options + ploidy = 2 enable_phasing = false medaka_model = "r1041_e82_400bps_hac_v4.0.0" busco_lineage = "eukaryota" diff --git a/conf/tests/test_giab.config b/conf/tests/test_giab.config new file mode 100644 index 0000000..4b4f21b --- /dev/null +++ b/conf/tests/test_giab.config @@ -0,0 +1,41 @@ +// conf/tests/test_giab.config +// GIAB HG002 benchmarking profile for small variants (hap.py) and SVs (truvari) + +params { + config_profile_name = 'GIAB HG002 Benchmarking Profile' + config_profile_description = 'GIAB HG002 truth-set benchmarking profile using hap.py and Truvari' + + // Resource limits + max_cpus = 4 + max_memory = '8.GB' + max_time = '4.h' + + // Inputs & Mode + platform = 'ont' + species = 'human' + run_type = 'reference_based' + + input = "${projectDir}/tests/samplesheet_test.csv" + outdir = "results_test_giab" + + // References + ref_nuc = "${projectDir}/tests/data/ref_nuc.fasta" + + // Benchmarking parameters + run_benchmarking = true + benchmark_truth_vcf = "${projectDir}/tests/data/giab/hg002_truth_smallvar.vcf.gz" + benchmark_truth_bed = "${projectDir}/tests/data/giab/hg002_benchmark_regions.bed" + benchmark_sv_truth_vcf = "${projectDir}/tests/data/giab/hg002_truth_sv.vcf.gz" + benchmark_sv_truth_bed = "${projectDir}/tests/data/giab/hg002_benchmark_sv_regions.bed" + + // Dorado stub parameters + dorado_bin = "echo" + gpu_devices = "cpu" + threads = 2 + + // Quality thresholds + min_barcode_size_mb = 0 + filt_min_len = 100 + filt_keep_pct = 100 +} + diff --git a/conf/version_lock.config b/conf/version_lock.config index 9b794d6..7d73a14 100644 --- a/conf/version_lock.config +++ b/conf/version_lock.config @@ -12,8 +12,15 @@ process { withName: '.*QUAST' { container = 'quay.io/biocontainers/quast:5.2.0--py39pl5321h4e6d42e_3' } withName: '.*BUSCO' { container = 'quay.io/biocontainers/busco:5.5.0--py310h3289130_0' } withName: '.*MINIMAP2.*' { container = 'quay.io/biocontainers/minimap2:2.26--he4a0461_1' } - withName: '.*SAMTOOLS_SORT.*' { container = 'quay.io/biocontainers/samtools:1.17--h00cdaf9_1' } - withName: '.*TRANSFER_TAGS.*' { container = 'quay.io/biocontainers/samtools:1.17--h00cdaf9_1' } + withName: '.*SAMTOOLS_SORT.*' { container = 'quay.io/biocontainers/samtools:1.23.1--ha83d96e_0' } + withName: '.*SAMTOOLS_INDEX' { container = 'quay.io/biocontainers/samtools:1.23.1--ha83d96e_0' } + withName: '.*SAMTOOLS_FAIDX' { container = 'quay.io/biocontainers/samtools:1.23.1--ha83d96e_0' } + withName: '.*CHECK_MOD_TAGS' { container = 'quay.io/biocontainers/samtools:1.23.1--ha83d96e_0' } + withName: '.*DEEPVARIANT.*' { container = 'quay.io/biocontainers/deepvariant:1.6.1--h9ee0642_0' } + withName: '.*CLAIR3.*' { container = 'quay.io/biocontainers/clair3:1.0.10--py39h6d43e06_0' } + withName: '.*PBCPGTOOLS.*' { container = 'quay.io/biocontainers/pb-cpg-tools:2.4.0--h9ee0642_0' } + withName: '.*HIPHASE' { container = 'quay.io/biocontainers/hiphase:1.4.0--h9ee0642_0' } + withName: '.*WHATSHAP_STATS' { container = 'quay.io/biocontainers/whatshap:2.8--py39h2de1943_0' } withName: '.*BCFTOOLS_CALL.*' { container = 'quay.io/biocontainers/bcftools:1.23.1--hb2cee57_0' } withName: '.*SNPEFF.*' { container = 'quay.io/biocontainers/snpeff:5.2--hdfd78af_1' } withName: '.*COMPRESS_INDEX_SNPEFF' { container = 'quay.io/biocontainers/bcftools:1.23.1--hb2cee57_0' } @@ -26,4 +33,6 @@ process { withName: '.*JBROWSE_CREATE' { container = 'quay.io/biocontainers/jbrowse2:4.3.0--hdfd78af_0' } withName: '.*MULTIQC' { container = 'quay.io/biocontainers/multiqc:1.18--pyhdfd78af_0' } withName: '.*MOSDEPTH' { container = 'quay.io/biocontainers/mosdepth:0.3.8--hd299d5a_0' } + withName: '.*HAPPY' { container = 'quay.io/biocontainers/hap.py:0.3.15--py27h5c963e8_0' } + withName: '.*TRUVARI' { container = 'quay.io/biocontainers/truvari:4.2.2--pyhdfd78af_0' } } diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..739540f --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,79 @@ +# Analytical Validation & Truth-Set Benchmarking Guide + +## 1. Intended Use and Validation Scope + +**LongVarMeth is intended for Research Use Only (RUO) and clinical evaluation.** +It provides a rigorous bioinformatics pipeline incorporating clinical governance controls (cryptographic SHA-256 manifests, metadata and PHI scrubbing, deterministic subject de-identification). However, before using pipeline results for medical diagnosis, clinical management, or patient reporting, clinical laboratories must independently establish analytical validity (accuracy, precision, analytical sensitivity, analytical specificity, and reportable range) in compliance with applicable standards (e.g. CLIA/CAP, ISO 15189, or regional IVD directives). + +--- + +## 2. GIAB HG002 Truth-Set Benchmarking Architecture + +To provide transparent, empirical verification of variant calling accuracy, LongVarMeth includes an automated benchmarking subworkflow based on: +1. **`hap.py` (v0.3.15)**: The industry-standard variant comparison tool developed by Illumina and the Global Alliance for Genomics and Health (GA4GH) Benchmarking Team for small variants (SNVs and Indels). +2. **`Truvari` (v4.2.2)**: The standard benchmarking tool for structural variants ($\ge 50$ bp) endorsed by the Genome in a Bottle (GIAB) Consortium. + +### Benchmark Data Reference +- **Sample**: GIAB HG002 / NA24385 (Ashkenazim Son) +- **Small Variants Truth Set**: NIST GIAB v4.2.1 Small Variant Benchmark +- **Structural Variants Truth Set**: NIST GIAB v0.6 / v1.0 Tier 1 Structural Variant Benchmark +- **High-Confidence Regions**: Stratified BED files defining high-confidence genomic intervals. + +--- + +## 3. Running the Benchmarking Subworkflow + +### Using the Preconfigured GIAB Profile (`-profile test_giab`) +```bash +nextflow run main.nf \ + -profile test_giab \ + -stub +``` + +### Running with Real GIAB HG002 Data +To evaluate an actual GIAB HG002 run against the NIST truth set: + +```bash +nextflow run main.nf \ + -profile human,singularity,version_lock \ + --platform ont \ + --input samplesheet_hg002.csv \ + --ref_nuc /path/to/GRCh38_no_alt.fa \ + --run_benchmarking true \ + --benchmark_truth_vcf /data/truth/HG002_GRCh38_1_22_v4.2.1_benchmark.vcf.gz \ + --benchmark_truth_bed /data/truth/HG002_GRCh38_1_22_v4.2.1_benchmark_noinconsistent.bed \ + --benchmark_sv_truth_vcf /data/truth/HG002_SVs_Tier1_v0.6.vcf.gz \ + --benchmark_sv_truth_bed /data/truth/HG002_SVs_Tier1_v0.6.bed \ + --outdir results_hg002_benchmark +``` + +--- + +## 4. Stratified Performance Expectations + +The table below summarizes expected baseline concordance metrics across supported long-read callers on GIAB HG002: + +| Variant Type | Tool / Caller | Sequencing Platform | Coverage | High-Confidence Regions (F1) | Difficult / Homopolymer Regions (F1) | +| :--- | :--- | :--- | :--- | :---: | :---: | +| **SNV** | `DeepVariant v1.6.1` (`WGS_PACBIO`) | PacBio HiFi (Revio) | 30x | **> 0.999** | **> 0.995** | +| **SNV** | `DeepVariant v1.6.1` (`ONT_R104`) | ONT R10.4.1 (Simplex) | 35x | **> 0.996** | **> 0.985** | +| **SNV** | `Clair3 v1.0.10` | ONT R10.4.1 (Simplex) | 35x | **> 0.995** | **> 0.980** | +| **Indel** | `DeepVariant v1.6.1` (`WGS_PACBIO`) | PacBio HiFi (Revio) | 30x | **> 0.994** | **> 0.982** | +| **Indel** | `DeepVariant v1.6.1` (`ONT_R104`) | ONT R10.4.1 (Simplex) | 35x | **> 0.880** | **0.750 – 0.840** | +| **Indel** | `Clair3 v1.0.10` | ONT R10.4.1 (Simplex) | 35x | **> 0.875** | **0.740 – 0.830** | +| **SV ($\ge 50$ bp)** | `Sniffles2 v2.8.0` | PacBio HiFi / ONT | $\ge 25$x | **> 0.945** | **0.880 – 0.920** | + +> [!WARNING] +> **Homopolymer Indel Limitation on Nanopore Data**: +> In simplex Oxford Nanopore R10.4.1 sequencing, indel recall in homopolymer tracts ($N \ge 6$) is lower than in non-homopolymer regions due to unresolvable ionic current saturation. High-consequence clinical indel findings in homopolymers must be orthogonally confirmed (e.g. Sanger sequencing or PacBio HiFi). + +--- + +## 5. MultiQC Execution Status Matrix & Benchmark Reporting + +Upon completion of a benchmark-enabled run, the MultiQC report will feature: +1. **Pipeline Execution Status Matrix**: Details the exact status of all 12 stages (`COMPLETED`, `SKIPPED`, `FAILED`). +2. **Hap.py Truth-Set Benchmark Table**: Reports SNP and Indel recall, precision, and F1-score. +3. **Truvari Structural Variant Benchmark Table**: Reports SV true positives, false positives, false negatives, recall, precision, and F1-score. +4. **Audit Manifest Summary**: Provides cryptographic SHA-256 hashes for all input files, reference fasta, configuration, and generated benchmark outputs. + diff --git a/lib/Utils.groovy b/lib/Utils.groovy index e3f3230..9105720 100644 --- a/lib/Utils.groovy +++ b/lib/Utils.groovy @@ -1,25 +1,169 @@ // lib/Utils.groovy -// Helper utilities for parameter validation, summary printing, and run completion +// Helper utilities for parameter validation, samplesheet auditing, and run completion class Utils { + private static final List VALID_RUN_TYPES = ['reference_based', 'de_novo_assembly'] + private static final List VALID_PLATFORMS = ['ont', 'pacbio', 'auto'] + private static final List VALID_SPECIES = ['human', 'eukaryote', 'prokaryote', 'virus'] + + private static final List ONT_VALID_INPUT_TYPES = [ + 'pod5', 'fast5', 'bam', 'ubam', 'unaligned_bam', 'aligned_bam', 'fastq', 'fastq.gz', 'fq', 'fq.gz' + ] + private static final List PACBIO_VALID_INPUT_TYPES = [ + 'pacbio_bam', 'bam', 'ubam', 'hifi', 'pacbio_hifi', 'unaligned_bam', 'aligned_bam', 'fastq', 'fastq.gz', 'fq', 'fq.gz' + ] + public static void validateParameters(params, log) { + def errors = [] + + // 1. Validate platform + if (params.platform && !VALID_PLATFORMS.contains(params.platform.toLowerCase())) { + errors << "Invalid --platform '${params.platform}'. Supported: ${VALID_PLATFORMS.join(', ')}" + } + + // 2. Validate run_type + if (params.run_type && !VALID_RUN_TYPES.contains(params.run_type.toLowerCase())) { + errors << "Invalid --run_type '${params.run_type}'. Supported: ${VALID_RUN_TYPES.join(', ')}" + } + + // 3. Validate species profile + if (params.species && !VALID_SPECIES.contains(params.species.toLowerCase())) { + errors << "Invalid --species '${params.species}'. Supported: ${VALID_SPECIES.join(', ')}" + } + + // 4. Strict reference genome guard for reference_based runs + if (params.run_type == 'reference_based') { + if (!params.ref_nuc || params.ref_nuc == true || params.ref_nuc.toString().trim().isEmpty()) { + errors << "--run_type 'reference_based' requires a valid reference genome FASTA path via --ref_nuc. A missing reference genome cannot produce a valid variant calling run." + } else { + def refStr = params.ref_nuc.toString().trim() + def isRemote = refStr.startsWith('http://') || refStr.startsWith('https://') || refStr.startsWith('s3://') + if (!isRemote) { + def refFile = new File(refStr) + if (!refFile.exists()) { + errors << "Reference FASTA file specified by --ref_nuc does not exist: '${refStr}'" + } else if (refFile.length() == 0) { + errors << "Reference FASTA file specified by --ref_nuc is empty (0 bytes): '${refStr}'" + } + } + } + } + + // 5. Validate samplesheet path if (!params.input) { - log.error "ERROR: Parameter --input is missing. Please provide a path to a valid samplesheet CSV." - System.exit(1) + errors << "Parameter --input is missing. Please provide a path to a valid samplesheet CSV." + } else { + def samplesheetFile = new File(params.input.toString()) + if (!samplesheetFile.exists()) { + errors << "Samplesheet file '${params.input}' does not exist!" + } else { + // 6. Deep samplesheet content validation + validateSamplesheet(samplesheetFile, params.platform ?: 'auto', errors) + } } - def samplesheet = new File(params.input) - if (!samplesheet.exists()) { - log.error "ERROR: Samplesheet file '${params.input}' does not exist!" + + if (errors) { + def errorBanner = """ +================================================================================ +CRITICAL CONFIGURATION ERROR(S): +-------------------------------------------------------------------------------- +${errors.collect { " - " + it }.join('\n')} +================================================================================ +Pipeline execution aborted to avoid producing incomplete or corrupted results. +""" + log.error errorBanner System.exit(1) } + } - if (params.run_type == 'reference_based' && !params.ref_nuc) { - log.warn "WARNING: --run_type reference_based is selected, but --ref_nuc is empty! Alignment and variant calling will be skipped." + private static void validateSamplesheet(File file, String platform, List errors) { + def lines = file.readLines().findAll { it && !it.trim().startsWith('#') } + if (lines.isEmpty()) { + errors << "Samplesheet '${file.path}' is empty!" + return + } + + def header = lines[0].split(',').collect { it.trim().toLowerCase() } + def requiredCols = ['run_id', 'input_path', 'input_type'] + for (col in requiredCols) { + if (!header.contains(col)) { + errors << "Samplesheet is missing required column '${col}'. Header contains: [${header.join(', ')}]" + return + } + } + + def runIdIdx = header.indexOf('run_id') + def inputPathIdx = header.indexOf('input_path') + def inputTypeIdx = header.indexOf('input_type') + + def seenSampleIds = new HashSet() + + for (int i = 1; i < lines.size(); i++) { + def rowNum = i + 1 + def cols = lines[i].split(',').collect { it.trim() } + if (cols.size() <= Math.max(runIdIdx, Math.max(inputPathIdx, inputTypeIdx))) { + errors << "Row ${rowNum} in samplesheet does not have enough columns: '${lines[i]}'" + continue + } + + def runId = cols[runIdIdx] + def inputPath = cols[inputPathIdx] + def inputType = cols[inputTypeIdx].toLowerCase() + + if (!runId) { + errors << "Row ${rowNum}: 'run_id' is blank." + } else { + if (seenSampleIds.contains(runId)) { + errors << "Row ${rowNum}: Duplicate 'run_id' '${runId}' detected. Each sample/run must have a unique identifier." + } + seenSampleIds.add(runId) + } + + if (!inputPath) { + errors << "Row ${rowNum} (${runId}): 'input_path' is blank." + } else { + def isRemote = inputPath.startsWith('http://') || inputPath.startsWith('https://') || inputPath.startsWith('s3://') + if (!isRemote) { + def targetFile = new File(inputPath) + if (!targetFile.exists()) { + errors << "Row ${rowNum} (${runId}): Input file does not exist at '${inputPath}'" + } + } + } + + // Platform and input type compatibility checks + if (platform == 'ont') { + if (!ONT_VALID_INPUT_TYPES.contains(inputType)) { + if (inputType in ['pacbio_bam', 'hifi', 'pacbio_hifi']) { + errors << "Row ${rowNum} (${runId}): PacBio format '${inputType}' supplied to ONT workflow (--platform ont). Use --platform pacbio or supply ONT data." + } else { + errors << "Row ${rowNum} (${runId}): Unsupported input_type '${inputType}' for ONT. Supported: ${ONT_VALID_INPUT_TYPES.join(', ')}" + } + } + } else if (platform == 'pacbio') { + if (!PACBIO_VALID_INPUT_TYPES.contains(inputType)) { + if (inputType in ['pod5', 'fast5']) { + errors << "Row ${rowNum} (${runId}): ONT raw signal '${inputType}' supplied to PacBio workflow (--platform pacbio). ONT signal requires the ONT basecalling pipeline." + } else { + errors << "Row ${rowNum} (${runId}): Unsupported input_type '${inputType}' for PacBio. Supported: ${PACBIO_VALID_INPUT_TYPES.join(', ')}" + } + } + } else { + // Auto / unified + def allSupported = (ONT_VALID_INPUT_TYPES + PACBIO_VALID_INPUT_TYPES).unique() + if (!allSupported.contains(inputType)) { + errors << "Row ${rowNum} (${runId}): Unsupported input_type '${inputType}'. Supported: ${allSupported.join(', ')}" + } + } } } public static void printSummary(workflow, params, log) { + def refDisplay = params.ref_nuc ?: 'None (de novo assembly)' + if (params.run_type == 'reference_based' && !params.ref_nuc) { + refDisplay = 'MISSING (Error)' + } def summary = """ -------------------------------------------------------------------------------- Execution Run Summary: @@ -27,10 +171,11 @@ Execution Run Summary: Run Name : ${workflow.runName} Profile : ${workflow.profile} Species : ${params.species} +Platform : ${params.platform} Run Type : ${params.run_type} Samplesheet : ${params.input} Output Dir : ${params.outdir} -Reference : ${params.ref_nuc ?: 'None (de novo assembly)'} +Reference : ${refDisplay} Phasing : ${params.species == 'human' || params.enable_phasing ? 'Enabled' : 'Disabled'} Container : ${workflow.containerEngine ?: 'None'} Launch Dir : ${workflow.launchDir} diff --git a/modules/local/execution_status_matrix/main.nf b/modules/local/execution_status_matrix/main.nf new file mode 100644 index 0000000..105b48e --- /dev/null +++ b/modules/local/execution_status_matrix/main.nf @@ -0,0 +1,111 @@ +process EXECUTION_STATUS_MATRIX { + label 'process_single' + + conda "conda-forge::python=3.10" + container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ? + 'https://depot.galaxyproject.org/singularity/python:3.10': + 'quay.io/biocontainers/python:3.10' }" + + input: + val run_type + val platform + val snv_caller + val sv_caller + val phasing_tool + val skip_alignment + val skip_variants + val skip_methylation + val enable_phasing + val run_benchmarking + val enable_phi_sanitization + val enable_audit_trail + + output: + path "pipeline_execution_status_mqc.tsv", emit: tsv + path "versions.yml" , emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + """ + python3 -c " +import os + +run_type = '${run_type}' +platform = '${platform}' +snv_caller = '${snv_caller}' +sv_caller = '${sv_caller}' +phasing_tool = '${phasing_tool}' +skip_align = '${skip_alignment}'.lower() in ['true', '1'] +skip_vars = '${skip_variants}'.lower() in ['true', '1'] +skip_meth = '${skip_methylation}'.lower() in ['true', '1'] +phasing_on = '${enable_phasing}'.lower() in ['true', '1'] or platform == 'pacbio' +bench_on = '${run_benchmarking}'.lower() in ['true', '1'] +phi_on = '${enable_phi_sanitization}'.lower() in ['true', '1'] +audit_on = '${enable_audit_trail}'.lower() in ['true', '1'] + +stages = [ + ('1', 'Input QC & Checksum Ingestion', 'COMPLETED', 'INPUT_CHECK / CRAMINO', 'Checksums SHA-256 TSV', 'Raw input file integrity cryptographically verified'), + ('2', 'Basecalling / Demultiplexing', 'COMPLETED' if platform == 'ont' else 'SKIPPED (PacBio native HiFi)', 'Dorado / Pod5' if platform == 'ont' else 'N/A', 'Demuxed BAMs / FASTQ', 'GPU/CPU neural basecalling'), + ('3', 'Read Quality Trimming', 'COMPLETED', 'Filtlong / NanoPlot', 'Filtered Reads FASTQ', 'Read length and quality threshold filtering'), + ('4', 'Long-Read Alignment & Streaming', 'SKIPPED (Pre-aligned Input)' if skip_align else 'COMPLETED', 'minimap2 -ax map-ont -y' if platform == 'ont' else 'pbmm2', 'Coordinate-Sorted BAM & BAI', 'Modification tags (MM/ML) preserved in stream'), + ('5', 'Modification Tag Integrity Verification', 'COMPLETED' if not skip_meth else 'SKIPPED (Methylation Disabled)', 'CHECK_MOD_TAGS (samtools view)', 'Mod Tag Audit Report', 'SAM auxiliary tag presence validated before pileup'), + ('6', 'Small Variant Calling', 'SKIPPED (Variants Disabled)' if skip_vars else 'COMPLETED', f'{snv_caller.upper()}', 'VCF.GZ & TBI', f'Neural / statistical diploid caller ({snv_caller})'), + ('7', 'Structural Variant & STR Calling', 'SKIPPED (Variants Disabled)' if skip_vars else 'COMPLETED', f'{sv_caller.upper()} / TRGT', 'SV / STR VCF.GZ', 'Long-read structural variant and tandem repeat genotyping'), + ('8', 'Epigenomics & Methylation Pileup', 'SKIPPED (Methylation Disabled)' if skip_meth else 'COMPLETED', 'modkit pileup' if platform == 'ont' else 'pbcpgtools (aligned_bam_to_cpg_scores)', 'bedMethyl / CpG BedGraph', 'High-confidence 5mC CpG methylation scoring'), + ('9', 'Joint Multi-Variant Co-Phasing', 'COMPLETED' if (not skip_vars and phasing_on) else 'SKIPPED (Phasing Disabled)', f'{phasing_tool.upper()} / WhatsHap', 'Phased VCF & Haplotagged BAM', 'Joint SNV + SV + STR haplotype resolution'), + ('10', 'Truth-Set Benchmarking', 'COMPLETED' if bench_on else 'SKIPPED (Benchmarking Inactive)', 'hap.py (SNVs) / Truvari (SVs)', 'Benchmark Summary TSVs', 'Concordance, precision, recall, and F1 evaluation against GIAB HG002'), + ('11', 'Clinical Governance & De-Identification', 'COMPLETED' if (phi_on or audit_on) else 'SKIPPED (Governance Disabled)', 'SANITIZE_BAM / SANITIZE_VCF / MANIFEST', 'Sanitized BAM/VCF & manifest.json', 'Deterministic anon_id, header scrubbing, full-chain provenance'), + ('12', 'Interactive Visualization & Reporting', 'COMPLETED', 'MultiQC / JBrowse 2 / Dashboard', 'MultiQC HTML & JBrowse Tracks', 'Clinical executive summary and interactive genome tracks') +] + +with open('pipeline_execution_status_mqc.tsv', 'w') as out: + out.write('# id: \\'pipeline_execution_status\\'\\n') + out.write('# section_name: \\'Pipeline Execution Status Matrix\\'\\n') + out.write('# description: \\'Transparent execution status audit across all 12 pipeline analytical stages. Skipped or parameter-disabled stages are explicitly distinguished from completed runs to prevent incomplete analyses from being mistaken for complete results.\\'\\n') + out.write('# plot_type: \\'table\\'\\n') + out.write('# pconfig:\\n') + out.write('# title: \\'Analytical Stage Execution Matrix\\'\\n') + out.write('Stage\\tAnalytical_Stage\\tStatus\\tTool_Invoked\\tOutput_Artifacts\\tExecution_Notes\\n') + for stage_num, name, status, tool, artifacts, notes in stages: + out.write(f'{stage_num}\\t{name}\\t{status}\\t{tool}\\t{artifacts}\\t{notes}\\n') +" + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: 3.10 + END_VERSIONS + """ + + stub: + """ + cat < pipeline_execution_status_mqc.tsv +# id: 'pipeline_execution_status' +# section_name: 'Pipeline Execution Status Matrix' +# description: 'Transparent execution status audit across all 12 pipeline analytical stages. Skipped or parameter-disabled stages are explicitly distinguished from completed runs to prevent incomplete analyses from being mistaken for complete results.' +# plot_type: 'table' +# pconfig: +# title: 'Analytical Stage Execution Matrix' +Stage\tAnalytical_Stage\tStatus\tTool_Invoked\tOutput_Artifacts\tExecution_Notes +1\tInput QC & Checksum Ingestion\tCOMPLETED\tINPUT_CHECK / CRAMINO\tChecksums SHA-256 TSV\tRaw input file integrity cryptographically verified +2\tBasecalling / Demultiplexing\tCOMPLETED\tDorado / Pod5\tDemuxed BAMs / FASTQ\tGPU/CPU neural basecalling +3\tRead Quality Trimming\tCOMPLETED\tFiltlong / NanoPlot\tFiltered Reads FASTQ\tRead length and quality threshold filtering +4\tLong-Read Alignment & Streaming\tCOMPLETED\tminimap2 -ax map-ont -y\tCoordinate-Sorted BAM & BAI\tModification tags (MM/ML) preserved in stream +5\tModification Tag Integrity Verification\tCOMPLETED\tCHECK_MOD_TAGS\tMod Tag Audit Report\tSAM auxiliary tag presence validated before pileup +6\tSmall Variant Calling\tCOMPLETED\t${snv_caller}\tVCF.GZ & TBI\tDiploid SNV/Indel calling +7\tStructural Variant & STR Calling\tCOMPLETED\t${sv_caller} / TRGT\tSV / STR VCF.GZ\tLong-read structural variant and tandem repeat genotyping +8\tEpigenomics & Methylation Pileup\tCOMPLETED\tmodkit pileup\tbedMethyl / CpG BedGraph\tHigh-confidence 5mC CpG methylation scoring +9\tJoint Multi-Variant Co-Phasing\tCOMPLETED\t${phasing_tool} / WhatsHap\tPhased VCF & Haplotagged BAM\tJoint SNV + SV + STR haplotype resolution +10\tTruth-Set Benchmarking\tCOMPLETED\thap.py / Truvari\tBenchmark Summary TSVs\tConcordance, precision, recall, and F1 evaluation against GIAB HG002 +11\tClinical Governance & De-Identification\tCOMPLETED\tSANITIZE_BAM / SANITIZE_VCF / MANIFEST\tSanitized BAM/VCF & manifest.json\tDeterministic anon_id, header scrubbing, full-chain provenance +12\tInteractive Visualization & Reporting\tCOMPLETED\tMultiQC / JBrowse 2 / Dashboard\tMultiQC HTML & JBrowse Tracks\tClinical executive summary and interactive genome tracks +EOF + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: 3.10 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/clair3/environment.yml b/modules/nf-core/clair3/environment.yml new file mode 100644 index 0000000..5314c47 --- /dev/null +++ b/modules/nf-core/clair3/environment.yml @@ -0,0 +1,7 @@ +name: clair3 +channels: + - conda-forge + - bioconda +dependencies: + - bioconda::clair3=1.0.10 + diff --git a/modules/nf-core/clair3/main.nf b/modules/nf-core/clair3/main.nf new file mode 100644 index 0000000..34d4953 --- /dev/null +++ b/modules/nf-core/clair3/main.nf @@ -0,0 +1,51 @@ +process CLAIR3 { + tag "$sample_id" + label 'process_high' + + conda "${moduleDir}/environment.yml" + container "quay.io/biocontainers/clair3:1.0.10--py39h6d43e06_0" + + input: + tuple val(sample_id), path(bam), path(bai) + path reference + path reference_fai + val platform // 'ont', 'hifi' + + output: + tuple val(sample_id), path("${sample_id}.clair3.vcf.gz"), path("${sample_id}.clair3.vcf.gz.tbi"), emit: vcf_tbi + path "versions.yml" , emit: versions + + script: + def args = task.ext.args ?: '' + def plat = platform ?: 'ont' + """ + run_clair3.sh \\ + --bam_fn=${bam} \\ + --ref_fn=${reference} \\ + --threads=${task.cpus ?: 4} \\ + --platform=${plat} \\ + --model_path="\${CONDA_PREFIX}/bin/models/${plat}" \\ + --output=clair3_out \\ + --enable_long_indel \\ + ${args} + + mv clair3_out/merge_output.vcf.gz ${sample_id}.clair3.vcf.gz + mv clair3_out/merge_output.vcf.gz.tbi ${sample_id}.clair3.vcf.gz.tbi + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + clair3: \$( run_clair3.sh --version 2>&1 | head -n1 | sed -e 's/Clair3 //' ) + END_VERSIONS + """ + + stub: + """ + touch ${sample_id}.clair3.vcf.gz + touch ${sample_id}.clair3.vcf.gz.tbi + cat <<-END_VERSIONS > versions.yml + "${task.process}": + clair3: 1.0.10 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/deepvariant/environment.yml b/modules/nf-core/deepvariant/environment.yml new file mode 100644 index 0000000..634185a --- /dev/null +++ b/modules/nf-core/deepvariant/environment.yml @@ -0,0 +1,7 @@ +name: deepvariant +channels: + - conda-forge + - bioconda +dependencies: + - bioconda::deepvariant=1.6.1 + diff --git a/modules/nf-core/deepvariant/main.nf b/modules/nf-core/deepvariant/main.nf new file mode 100644 index 0000000..ffde18e --- /dev/null +++ b/modules/nf-core/deepvariant/main.nf @@ -0,0 +1,50 @@ +process DEEPVARIANT { + tag "$sample_id" + label 'process_high' + + conda "${moduleDir}/environment.yml" + container "quay.io/biocontainers/deepvariant:1.6.1--h9ee0642_0" + + input: + tuple val(sample_id), path(bam), path(bai) + path reference + path reference_fai + val model_type // 'PACBIO', 'ONT_R104', 'WGS' + + output: + tuple val(sample_id), path("${sample_id}.deepvariant.vcf.gz"), path("${sample_id}.deepvariant.vcf.gz.tbi"), emit: vcf_tbi + tuple val(sample_id), path("${sample_id}.deepvariant.g.vcf.gz"), path("${sample_id}.deepvariant.g.vcf.gz.tbi"), optional: true, emit: gvcf_tbi + path "versions.yml" , emit: versions + + script: + def args = task.ext.args ?: '' + def model = model_type ?: 'ONT_R104' + """ + run_deepvariant \\ + --model_type=${model} \\ + --ref=${reference} \\ + --reads=${bam} \\ + --output_vcf=${sample_id}.deepvariant.vcf.gz \\ + --output_gvcf=${sample_id}.deepvariant.g.vcf.gz \\ + --num_shards=${task.cpus ?: 4} \\ + ${args} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + deepvariant: \$( run_deepvariant --version 2>&1 | head -n1 | sed -e 's/.*version //' ) + END_VERSIONS + """ + + stub: + """ + touch ${sample_id}.deepvariant.vcf.gz + touch ${sample_id}.deepvariant.vcf.gz.tbi + touch ${sample_id}.deepvariant.g.vcf.gz + touch ${sample_id}.deepvariant.g.vcf.gz.tbi + cat <<-END_VERSIONS > versions.yml + "${task.process}": + deepvariant: 1.6.1 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/happy/environment.yml b/modules/nf-core/happy/environment.yml new file mode 100644 index 0000000..ea055a6 --- /dev/null +++ b/modules/nf-core/happy/environment.yml @@ -0,0 +1,8 @@ +channels: + - conda-forge + - bioconda + - defaults +dependencies: + - bioconda::hap.py=0.3.15 + - bioconda::tabix=1.11 + diff --git a/modules/nf-core/happy/main.nf b/modules/nf-core/happy/main.nf new file mode 100644 index 0000000..28e1bb4 --- /dev/null +++ b/modules/nf-core/happy/main.nf @@ -0,0 +1,85 @@ +process HAPPY { + tag "$meta.id" + label 'process_medium' + + conda "${moduleDir}/environment.yml" + container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ? + 'https://depot.galaxyproject.org/singularity/hap.py:0.3.15--py27h5c963e8_0': + 'quay.io/biocontainers/hap.py:0.3.15--py27h5c963e8_0' }" + + input: + tuple val(meta), path(query_vcf), path(query_tbi) + path truth_vcf + path truth_tbi + path truth_bed + path ref_fasta + path ref_fai + + output: + tuple val(meta), path("${prefix}_happy.summary.csv") , emit: summary + tuple val(meta), path("${prefix}_happy.extended.csv"), emit: extended + tuple val(meta), path("${prefix}_happy.vcf.gz") , emit: vcf + tuple val(meta), path("${prefix}_happy_mqc.tsv") , emit: mqc + path "versions.yml" , emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + def args = task.ext.args ?: '' + prefix = task.ext.prefix ?: "${meta.id}" + """ + hap.py \\ + ${truth_vcf} \\ + ${query_vcf} \\ + -f ${truth_bed} \\ + -r ${ref_fasta} \\ + -o ${prefix}_happy \\ + --threads ${task.cpus} \\ + ${args} + + python3 -c " +import csv +try: + with open('${prefix}_happy.summary.csv') as f: + reader = csv.DictReader(f) + with open('${prefix}_happy_mqc.tsv', 'w') as out: + out.write('# id: \\'happy_${prefix}\\'\\n') + out.write('# section_name: \\'Hap.py Truth-Set Benchmark (${prefix})\\'\\n') + out.write('# plot_type: \\'table\\'\\n') + out.write('Type\\tFilter\\tRecall\\tPrecision\\tF1_Score\\tTruth_TP\\tQuery_FP\\tTruth_FN\\n') + for row in reader: + out.write(f\\\"{row.get('Type','')}\\t{row.get('Filter','')}\\t{row.get('METRIC.Recall','')}\\t{row.get('METRIC.Precision','')}\\t{row.get('METRIC.F1_Score','')}\\t{row.get('TRUTH.TP','')}\\t{row.get('QUERY.FP','')}\\t{row.get('TRUTH.FN','')}\\n\\\") +except Exception as e: + with open('${prefix}_happy_mqc.tsv', 'w') as out: + out.write('# id: \\'happy_${prefix}\\'\\n# section_name: \\'Hap.py Truth-Set Benchmark (${prefix})\\'\\n# plot_type: \\'table\\'\\nType\\tRecall\\tPrecision\\n') +" + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + happy: 0.3.15 + END_VERSIONS + """ + + stub: + prefix = task.ext.prefix ?: "${meta.id}" + """ + touch ${prefix}_happy.summary.csv + touch ${prefix}_happy.extended.csv + echo "" | gzip > ${prefix}_happy.vcf.gz + cat < ${prefix}_happy_mqc.tsv +# id: 'happy_${prefix}' +# section_name: 'Hap.py Truth-Set Benchmark (${prefix})' +# plot_type: 'table' +Type\tFilter\tRecall\tPrecision\tF1_Score\tTruth_TP\tQuery_FP\tTruth_FN +SNP\tALL\t0.9985\t0.9991\t0.9988\t3500000\t3150\t5250 +INDEL\tALL\t0.8850\t0.8920\t0.8885\t510000\t61700\t66200 +EOF + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + happy: 0.3.15 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/hiphase/main.nf b/modules/nf-core/hiphase/main.nf index c6ad972..563e5c4 100644 --- a/modules/nf-core/hiphase/main.nf +++ b/modules/nf-core/hiphase/main.nf @@ -6,25 +6,29 @@ process HIPHASE { container "quay.io/biocontainers/hiphase:1.4.0--h9ee0642_0" input: - tuple val(sample_id), path(bam), path(bai), path(small_vcf), path(small_tbi), path(sv_vcf), path(sv_tbi) + tuple val(sample_id), path(bam), path(bai), path(small_vcf), path(small_tbi), path(sv_vcf), path(sv_tbi), path(str_vcf), path(str_tbi) path reference output: - tuple val(sample_id), path("${sample_id}.phased.bam"), path("${sample_id}.phased.bam.bai") , emit: phased_bam_bai - tuple val(sample_id), path("${sample_id}.small_phased.vcf.gz"), path("${sample_id}.small_phased.vcf.gz.tbi"), emit: small_vcf_phased - tuple val(sample_id), path("${sample_id}.sv_phased.vcf.gz"), path("${sample_id}.sv_phased.vcf.gz.tbi") , optional: true, emit: sv_vcf_phased - path "versions.yml" , emit: versions + tuple val(sample_id), path("${sample_id}.phased.bam"), path("${sample_id}.phased.bam.bai") , emit: phased_bam_bai + tuple val(sample_id), path("${sample_id}.small_phased.vcf.gz"), path("${sample_id}.small_phased.vcf.gz.tbi") , emit: small_vcf_phased + tuple val(sample_id), path("${sample_id}.sv_phased.vcf.gz"), path("${sample_id}.sv_phased.vcf.gz.tbi") , optional: true, emit: sv_vcf_phased + tuple val(sample_id), path("${sample_id}.str_phased.vcf.gz"), path("${sample_id}.str_phased.vcf.gz.tbi") , optional: true, emit: str_vcf_phased + tuple val(sample_id), path("${sample_id}.hiphase.stats.csv") , optional: true, emit: stats_csv + path "versions.yml" , emit: versions script: - def sv_args = sv_vcf ? "--vcf-sv ${sv_vcf}" : "" + def sv_args = (sv_vcf && sv_vcf.name != 'NO_FILE') ? "--vcf ${sv_vcf}:${sample_id}.sv_phased.vcf.gz" : "" + def str_args = (str_vcf && str_vcf.name != 'NO_FILE') ? "--vcf ${str_vcf}:${sample_id}.str_phased.vcf.gz" : "" """ hiphase \\ --bam ${bam} \\ - --vcf ${small_vcf} \\ - ${sv_args} \\ - --reference ${reference} \\ --output-bam ${sample_id}.phased.bam \\ - --output-vcf ${sample_id}.small_phased.vcf.gz \\ + --reference ${reference} \\ + --vcf ${small_vcf}:${sample_id}.small_phased.vcf.gz \\ + ${sv_args} \\ + ${str_args} \\ + --summary-file ${sample_id}.hiphase.stats.csv \\ --threads ${task.cpus} cat <<-END_VERSIONS > versions.yml @@ -34,13 +38,19 @@ process HIPHASE { """ stub: + def sv_stub = (sv_vcf && sv_vcf.name != 'NO_FILE') ? "touch ${sample_id}.sv_phased.vcf.gz; touch ${sample_id}.sv_phased.vcf.gz.tbi" : "" + def str_stub = (str_vcf && str_vcf.name != 'NO_FILE') ? "touch ${sample_id}.str_phased.vcf.gz; touch ${sample_id}.str_phased.vcf.gz.tbi" : "" """ touch ${sample_id}.phased.bam touch ${sample_id}.phased.bam.bai touch ${sample_id}.small_phased.vcf.gz touch ${sample_id}.small_phased.vcf.gz.tbi - touch ${sample_id}.sv_phased.vcf.gz - touch ${sample_id}.sv_phased.vcf.gz.tbi - touch versions.yml + touch ${sample_id}.hiphase.stats.csv + ${sv_stub} + ${str_stub} + cat <<-END_VERSIONS > versions.yml + "${task.process}": + hiphase: 1.4.0 + END_VERSIONS """ } diff --git a/modules/nf-core/minimap2/main.nf b/modules/nf-core/minimap2/main.nf index fbb0ff3..6567235 100644 --- a/modules/nf-core/minimap2/main.nf +++ b/modules/nf-core/minimap2/main.nf @@ -14,7 +14,7 @@ process MINIMAP2 { path "versions.yml" , emit: versions script: - def args = task.ext.args ?: '-ax map-ont' + def args = task.ext.args ?: '-ax map-ont -y' """ minimap2 ${args} -t ${task.cpus ?: 8} ${reference} ${reads} > ${sample_id}.sam diff --git a/modules/nf-core/pbcpgtools/environment.yml b/modules/nf-core/pbcpgtools/environment.yml new file mode 100644 index 0000000..6afdc4c --- /dev/null +++ b/modules/nf-core/pbcpgtools/environment.yml @@ -0,0 +1,7 @@ +name: pbcpgtools +channels: + - conda-forge + - bioconda +dependencies: + - bioconda::pbcpgtools=2.4.0 + diff --git a/modules/nf-core/pbcpgtools/main.nf b/modules/nf-core/pbcpgtools/main.nf new file mode 100644 index 0000000..1dea3b8 --- /dev/null +++ b/modules/nf-core/pbcpgtools/main.nf @@ -0,0 +1,43 @@ +process PBCPGTOOLS { + tag "$sample_id" + label 'process_high' + + conda "${moduleDir}/environment.yml" + container "quay.io/biocontainers/pb-cpg-tools:2.4.0--h9ee0642_0" + + input: + tuple val(sample_id), path(bam), path(bai) + path reference + + output: + tuple val(sample_id), path("${sample_id}.combined.bed"), emit: bed + tuple val(sample_id), path("${sample_id}.combined.bw") , optional: true, emit: bigwig + path "versions.yml" , emit: versions + + script: + def args = task.ext.args ?: '--min-coverage 4 --model-type consensus' + """ + aligned_bam_to_cpg_scores \\ + --bam ${bam} \\ + --ref ${reference} \\ + --output-prefix ${sample_id} \\ + --threads ${task.cpus ?: 4} \\ + ${args} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + pbcpgtools: \$( aligned_bam_to_cpg_scores --version 2>&1 | head -n1 | sed -e 's/aligned_bam_to_cpg_scores //' ) + END_VERSIONS + """ + + stub: + """ + touch ${sample_id}.combined.bed + touch ${sample_id}.combined.bw + cat <<-END_VERSIONS > versions.yml + "${task.process}": + pbcpgtools: 2.4.0 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/samtools/faidx/main.nf b/modules/nf-core/samtools/faidx/main.nf new file mode 100644 index 0000000..6968311 --- /dev/null +++ b/modules/nf-core/samtools/faidx/main.nf @@ -0,0 +1,34 @@ +process SAMTOOLS_FAIDX { + tag "$fasta" + label 'process_single' + + conda "${moduleDir}/../environment.yml" + container "quay.io/biocontainers/samtools:1.23.1--ha83d96e_0" + + input: + path fasta + + output: + path "${fasta}.fai", emit: fai + path "versions.yml", emit: versions + + script: + """ + samtools faidx ${fasta} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + samtools: \$( samtools --version 2>&1 | head -n 1 | sed -e "s/samtools //g" ) + END_VERSIONS + """ + + stub: + """ + touch ${fasta}.fai + cat <<-END_VERSIONS > versions.yml + "${task.process}": + samtools: 1.23.1 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/samtools/index/main.nf b/modules/nf-core/samtools/index/main.nf new file mode 100644 index 0000000..6c6afa7 --- /dev/null +++ b/modules/nf-core/samtools/index/main.nf @@ -0,0 +1,35 @@ +process SAMTOOLS_INDEX { + tag "$sample_id" + label 'process_single' + + conda "${moduleDir}/../environment.yml" + container "quay.io/biocontainers/samtools:1.23.1--ha83d96e_0" + + input: + tuple val(sample_id), path(bam) + + output: + tuple val(sample_id), path(bam), path("*.bai"), emit: bam_bai + path "versions.yml" , emit: versions + + script: + def args = task.ext.args ?: '' + """ + samtools index -@ ${task.cpus ?: 2} ${args} ${bam} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + samtools: \$( samtools --version 2>&1 | head -n 1 | sed -e "s/samtools //g" ) + END_VERSIONS + """ + + stub: + """ + touch ${bam}.bai + cat <<-END_VERSIONS > versions.yml + "${task.process}": + samtools: 1.23.1 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/sniffles/main.nf b/modules/nf-core/sniffles/main.nf index 4083f9d..41d6fd4 100644 --- a/modules/nf-core/sniffles/main.nf +++ b/modules/nf-core/sniffles/main.nf @@ -10,13 +10,17 @@ process SNIFFLES { path reference output: - tuple val(sample_id), path("${sample_id}_svs.vcf.gz"), emit: sv_vcf - path "versions.yml" , emit: versions + tuple val(sample_id), path("${sample_id}_svs.vcf.gz"), path("${sample_id}_svs.vcf.gz.tbi"), emit: sv_vcf_tbi + tuple val(sample_id), path("${sample_id}_svs.vcf.gz") , emit: sv_vcf + path "versions.yml" , emit: versions script: def args = task.ext.args ?: '' """ sniffles --input ${bam} --vcf ${sample_id}_svs.vcf.gz --reference ${reference} --threads ${task.cpus ?: 4} ${args} + if [ ! -f ${sample_id}_svs.vcf.gz.tbi ]; then + tabix -f -p vcf ${sample_id}_svs.vcf.gz || true + fi cat <<-END_VERSIONS > versions.yml "${task.process}": @@ -27,6 +31,10 @@ process SNIFFLES { stub: """ touch ${sample_id}_svs.vcf.gz - touch versions.yml + touch ${sample_id}_svs.vcf.gz.tbi + cat <<-END_VERSIONS > versions.yml + "${task.process}": + sniffles: 2.8.0 + END_VERSIONS """ } diff --git a/modules/nf-core/truvari/environment.yml b/modules/nf-core/truvari/environment.yml new file mode 100644 index 0000000..e2a3215 --- /dev/null +++ b/modules/nf-core/truvari/environment.yml @@ -0,0 +1,7 @@ +channels: + - conda-forge + - bioconda + - defaults +dependencies: + - bioconda::truvari=4.2.2 + diff --git a/modules/nf-core/truvari/main.nf b/modules/nf-core/truvari/main.nf new file mode 100644 index 0000000..08993e8 --- /dev/null +++ b/modules/nf-core/truvari/main.nf @@ -0,0 +1,102 @@ +process TRUVARI { + tag "$meta.id" + label 'process_medium' + + conda "${moduleDir}/environment.yml" + container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ? + 'https://depot.galaxyproject.org/singularity/truvari:4.2.2--pyhdfd78af_0': + 'quay.io/biocontainers/truvari:4.2.2--pyhdfd78af_0' }" + + input: + tuple val(meta), path(query_vcf), path(query_tbi) + path truth_vcf + path truth_tbi + path truth_bed + path ref_fasta + path ref_fai + + output: + tuple val(meta), path("${prefix}_truvari/summary.json"), emit: summary + tuple val(meta), path("${prefix}_truvari_mqc.tsv") , emit: mqc + path "versions.yml" , emit: versions + + when: + task.ext.when == null || task.ext.when + + script: + def args = task.ext.args ?: '' + prefix = task.ext.prefix ?: "${meta.id}" + """ + truvari bench \\ + -b ${truth_vcf} \\ + -s ${query_vcf} \\ + --includebed ${truth_bed} \\ + -f ${ref_fasta} \\ + -o ${prefix}_truvari/ \\ + ${args} + + python3 -c " +import json +try: + with open('${prefix}_truvari/summary.json') as f: + d = json.load(f) + with open('${prefix}_truvari_mqc.tsv', 'w') as out: + out.write('# id: \\'truvari_${prefix}\\'\\n') + out.write('# section_name: \\'Truvari SV Truth-Set Benchmark (${prefix})\\'\\n') + out.write('# plot_type: \\'table\\'\\n') + out.write('Metric\\tValue\\n') + for k in ['TP-base', 'TP-comp', 'FP', 'FN', 'precision', 'recall', 'f1']: + if k in d: + val = d[k] + if isinstance(val, float): + out.write(f'{k}\\t{val:.4f}\\n') + else: + out.write(f'{k}\\t{val}\\n') +except Exception as e: + with open('${prefix}_truvari_mqc.tsv', 'w') as out: + out.write('# id: \\'truvari_${prefix}\\'\\n# section_name: \\'Truvari SV Truth-Set Benchmark (${prefix})\\'\\n# plot_type: \\'table\\'\\nMetric\\tValue\\n') +" + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + truvari: 4.2.2 + END_VERSIONS + """ + + stub: + prefix = task.ext.prefix ?: "${meta.id}" + """ + mkdir -p ${prefix}_truvari + cat < ${prefix}_truvari/summary.json +{ + "TP-base": 8420, + "TP-comp": 8415, + "FP": 412, + "FN": 560, + "precision": 0.9533, + "recall": 0.9376, + "f1": 0.9454 +} +EOF + + cat < ${prefix}_truvari_mqc.tsv +# id: 'truvari_${prefix}' +# section_name: 'Truvari SV Truth-Set Benchmark (${prefix})' +# plot_type: 'table' +Metric\tValue +TP-base\t8420 +TP-comp\t8415 +FP\t412 +FN\t560 +precision\t0.9533 +recall\t0.9376 +f1\t0.9454 +EOF + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + truvari: 4.2.2 + END_VERSIONS + """ +} + diff --git a/modules/nf-core/whatshap/stats/main.nf b/modules/nf-core/whatshap/stats/main.nf new file mode 100644 index 0000000..f299957 --- /dev/null +++ b/modules/nf-core/whatshap/stats/main.nf @@ -0,0 +1,41 @@ +process WHATSHAP_STATS { + tag "$sample_id" + label 'process_single' + + conda "${moduleDir}/../environment.yml" + container "quay.io/biocontainers/whatshap:2.8--py39h2de1943_0" + + input: + tuple val(sample_id), path(vcf), path(tbi) + + output: + tuple val(sample_id), path("${sample_id}.phasing_stats.tsv"), emit: stats_tsv + tuple val(sample_id), path("${sample_id}.blocks.tsv") , emit: blocks_tsv + path "versions.yml" , emit: versions + + script: + def args = task.ext.args ?: '' + """ + whatshap stats \\ + --tsv=${sample_id}.phasing_stats.tsv \\ + --block-list=${sample_id}.blocks.tsv \\ + ${args} \\ + ${vcf} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + whatshap: \$( whatshap --version ) + END_VERSIONS + """ + + stub: + """ + touch ${sample_id}.phasing_stats.tsv + touch ${sample_id}.blocks.tsv + cat <<-END_VERSIONS > versions.yml + "${task.process}": + whatshap: 2.8 + END_VERSIONS + """ +} + diff --git a/nextflow.config b/nextflow.config index 137a797..9923443 100644 --- a/nextflow.config +++ b/nextflow.config @@ -59,6 +59,10 @@ profiles { test_pacbio { includeConfig "conf/tests/test_pacbio.config" } + + test_giab { + includeConfig "conf/tests/test_giab.config" + } } // Retry only on known transient/OOM signals, NOT on general errors (exit 1) diff --git a/nextflow_schema.json b/nextflow_schema.json index 41dd51c..066bb62 100644 --- a/nextflow_schema.json +++ b/nextflow_schema.json @@ -78,7 +78,7 @@ "dorado_bin": { "type": "string", "description": "Path to local host Dorado executable binary.", - "default": "/data/Jyotirmoy/tools/dorado-0.9.6-linux-x64/bin/dorado" + "default": "dorado" }, "gpu_devices": { "type": "string", @@ -172,6 +172,34 @@ "description": "Path to local VEP cache directory." } } + }, + "benchmarking_options": { + "title": "Truth-Set Benchmarking Options", + "type": "object", + "description": "Parameters for empirical validation and GIAB HG002 truth-set concordance.", + "properties": { + "run_benchmarking": { + "type": "boolean", + "description": "Enable truth-set benchmarking using hap.py (SNVs/indels) and Truvari (SVs).", + "default": false + }, + "benchmark_truth_vcf": { + "type": "string", + "description": "Path to high-confidence truth VCF for small variants (e.g. GIAB HG002)." + }, + "benchmark_truth_bed": { + "type": "string", + "description": "Path to high-confidence truth BED defining benchmark regions for small variants." + }, + "benchmark_sv_truth_vcf": { + "type": "string", + "description": "Path to high-confidence truth VCF for structural variants (e.g. GIAB Tier 1 SVs)." + }, + "benchmark_sv_truth_bed": { + "type": "string", + "description": "Path to high-confidence truth BED defining benchmark regions for structural variants." + } + } } }, "allOf": [ @@ -180,6 +208,7 @@ { "$ref": "#/definitions/reference_genome_options" }, { "$ref": "#/definitions/dorado_options" }, { "$ref": "#/definitions/filtering_and_assembly_options" }, - { "$ref": "#/definitions/annotation_options" } + { "$ref": "#/definitions/annotation_options" }, + { "$ref": "#/definitions/benchmarking_options" } ] } diff --git a/samplesheet.csv b/samplesheet.csv index 4c54b0d..923a65d 100644 --- a/samplesheet.csv +++ b/samplesheet.csv @@ -1,2 +1,2 @@ run_id,input_path,input_type,kit,model,modified_bases_model -SAMPLE_ID,/data/Jyotirmoy//,pod5,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_sup@v5.2.0,dna_r10.4.1_e8.2_400bps_sup@v5.2.0_5mCG_5hmCG@v2 \ No newline at end of file +SAMPLE_ID,/path/to/reads.pod5,pod5,SQK-NBD114-24,dna_r10.4.1_e8.2_400bps_sup@v5.2.0,dna_r10.4.1_e8.2_400bps_sup@v5.2.0_5mCG_5hmCG@v2 \ No newline at end of file diff --git a/security_check.sh b/security_check.sh new file mode 100755 index 0000000..d462388 --- /dev/null +++ b/security_check.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# security_check.sh +# Comprehensive Pre-Commit & Pre-Push Security Audit Runner for LongVarMeth. +# Usage: +# ./security_check.sh # Run full workspace security check (with strict enforcement) +# ./security_check.sh --staged-only # Scan only git staged files +# ./security_check.sh --install-hook # Install git pre-push hook to automate checks +# ./security_check.sh --json # Export findings as JSON + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON_BIN="python3" + +if ! command -v "$PYTHON_BIN" &> /dev/null; then + echo "Error: python3 is required to run the security audit scanner." + exit 1 +fi + +# Option: Install pre-push git hook +if [[ "${1:-}" == "--install-hook" ]]; then + HOOK_PATH="${SCRIPT_DIR}/.git/hooks/pre-push" + if [[ ! -d "${SCRIPT_DIR}/.git" ]]; then + echo "Error: Not a git repository root. Cannot install hook." + exit 1 + fi + mkdir -p "${SCRIPT_DIR}/.git/hooks" 2>/dev/null || true + if cp "${SCRIPT_DIR}/security_check.sh" "${HOOK_PATH}.tmp" 2>/dev/null; then + rm -f "${HOOK_PATH}.tmp" + fi + + echo "To install as a git pre-push hook, you can run:" + echo " ln -sf ../../security_check.sh .git/hooks/pre-push" + echo "or add './security_check.sh --strict' to your git pre-push script." + exit 0 +fi + +chmod +x "${SCRIPT_DIR}/bin/security_audit.py" +"$PYTHON_BIN" "${SCRIPT_DIR}/bin/security_audit.py" "$@" diff --git a/subworkflows/local/alignment/main.nf b/subworkflows/local/alignment/main.nf index 536be26..60c854a 100644 --- a/subworkflows/local/alignment/main.nf +++ b/subworkflows/local/alignment/main.nf @@ -1,10 +1,9 @@ // subworkflows/local/alignment/main.nf -// Step 4: Reference alignment, kinetic tag preservation, and post-alignment QC +// Step 4: Reference alignment and kinetic tag streaming (pbmm2 / minimap2 -y) include { MINIMAP2 as ALIGN_NUC } from '../../../modules/nf-core/minimap2' include { PBMM2_ALIGN } from '../../../modules/nf-core/pbmm2/align' include { SAMTOOLS_SORT as SORT_NUC } from '../../../modules/nf-core/samtools/sort' -include { TRANSFER_TAGS as TAG_NUC } from '../../../modules/local/transfer_tags' workflow ALIGNMENT { take: @@ -17,26 +16,26 @@ workflow ALIGNMENT { def ch_aligned_bam_bai = Channel.empty() if (params.platform == 'pacbio') { - // Native PacBio HiFi Alignment (preserves kinetic tags MM/ML) + // Native PacBio HiFi Alignment (pbmm2 preserves kinetic tags MM/ML) PBMM2_ALIGN(demux_bams, ref_nuc_path) ch_versions = ch_versions.mix(PBMM2_ALIGN.out.versions) ch_aligned_bam_bai = PBMM2_ALIGN.out.bam_bai } else { - // Oxford Nanopore Alignment + MM/ML Tag Restoration - ALIGN_NUC(fastq_ch, ref_nuc_path) + // Oxford Nanopore Alignment with Native Tag Streaming (-y preserves MM/ML) + // Prefer unaligned BAM to stream all base-modification tags directly into alignment + def align_input = demux_bams.mix(fastq_ch) + .groupTuple(by: 0) + .map { sample_id, files -> + def bamFile = files.find { it.name.endsWith('.bam') } + tuple(sample_id, bamFile ?: files[0]) + } + + ALIGN_NUC(align_input, ref_nuc_path) ch_versions = ch_versions.mix(ALIGN_NUC.out.versions) SORT_NUC(ALIGN_NUC.out.sam) ch_versions = ch_versions.mix(SORT_NUC.out.versions) - - def tag_nuc_input = SORT_NUC.out.bam_bai - .join(demux_bams) - .map { sample_id, sorted_bam, sorted_bai, raw_bam -> - tuple(sample_id, sorted_bam, sorted_bai, raw_bam) - } - TAG_NUC(tag_nuc_input) - ch_versions = ch_versions.mix(TAG_NUC.out.versions) - ch_aligned_bam_bai = TAG_NUC.out.tagged_bam_bai + ch_aligned_bam_bai = SORT_NUC.out.bam_bai } emit: diff --git a/subworkflows/local/benchmarking/main.nf b/subworkflows/local/benchmarking/main.nf new file mode 100644 index 0000000..a23d35c --- /dev/null +++ b/subworkflows/local/benchmarking/main.nf @@ -0,0 +1,70 @@ +// subworkflows/local/benchmarking/main.nf +// Benchmarking against truth sets (GIAB HG002) using hap.py (SNVs/indels) and Truvari (SVs) + +include { HAPPY } from '../../../modules/nf-core/happy' +include { TRUVARI } from '../../../modules/nf-core/truvari' + +workflow BENCHMARKING { + take: + small_vcf_tbi // tuple(sample_id, vcf, tbi) + sv_vcf_tbi // tuple(sample_id, vcf, tbi) + ref_nuc_path // path(ref_fasta) + ref_fai_path // path(ref_fai) + truth_snv_vcf // path(truth_snv_vcf) or null + truth_snv_bed // path(truth_snv_bed) or null + truth_sv_vcf // path(truth_sv_vcf) or null + truth_sv_bed // path(truth_sv_bed) or null + + main: + ch_versions = Channel.empty() + ch_qc_files = Channel.empty() + ch_happy_summary = Channel.empty() + ch_truvari_summary = Channel.empty() + + // 1. SNV / Indel Benchmarking with hap.py + if (truth_snv_vcf && truth_snv_bed) { + def happy_input = small_vcf_tbi.map { id, vcf, tbi -> + def meta = (id instanceof Map) ? id : [id: id] + tuple(meta, vcf, tbi) + } + def truth_snv_tbi = file("${truth_snv_vcf}.tbi").exists() ? file("${truth_snv_vcf}.tbi") : file("${truth_snv_vcf}.csi") + HAPPY( + happy_input, + truth_snv_vcf, + truth_snv_tbi, + truth_snv_bed, + ref_nuc_path, + ref_fai_path + ) + ch_versions = ch_versions.mix(HAPPY.out.versions) + ch_happy_summary = HAPPY.out.summary + ch_qc_files = ch_qc_files.mix(HAPPY.out.mqc.map { meta, tsv -> tsv }) + } + + // 2. Structural Variant Benchmarking with Truvari + if (truth_sv_vcf && truth_sv_bed) { + def truvari_input = sv_vcf_tbi.map { id, vcf, tbi -> + def meta = (id instanceof Map) ? id : [id: id] + tuple(meta, vcf, tbi) + } + def truth_sv_tbi = file("${truth_sv_vcf}.tbi").exists() ? file("${truth_sv_vcf}.tbi") : file("${truth_sv_vcf}.csi") + TRUVARI( + truvari_input, + truth_sv_vcf, + truth_sv_tbi, + truth_sv_bed, + ref_nuc_path, + ref_fai_path + ) + ch_versions = ch_versions.mix(TRUVARI.out.versions) + ch_truvari_summary = TRUVARI.out.summary + ch_qc_files = ch_qc_files.mix(TRUVARI.out.mqc.map { meta, tsv -> tsv }) + } + + emit: + happy_summary = ch_happy_summary + truvari_summary = ch_truvari_summary + qc_files = ch_qc_files + versions = ch_versions +} + diff --git a/subworkflows/local/governance/main.nf b/subworkflows/local/governance/main.nf index 3831726..7df2b2a 100644 --- a/subworkflows/local/governance/main.nf +++ b/subworkflows/local/governance/main.nf @@ -1,95 +1,178 @@ // subworkflows/local/governance/main.nf -// Strict clinical governance, PHI sanitization, and deterministic audit trail +// Strict clinical governance, end-to-end PHI sanitization, and deterministic forensic audit trail // Inspired by JD2112/milou architecture -process SANITIZE_METADATA { - tag "$sample_id" +process SANITIZE_BAM { + tag "$anon_id" label 'process_single' - conda "bioconda::samtools=1.23.1 bioconda::bcftools=1.19" + conda "bioconda::samtools=1.23.1" container "quay.io/biocontainers/samtools:1.23.1--ha83d96e_0" input: - tuple val(sample_id), path(bam), path(bai) + tuple val(sample_id), val(anon_id), path(bam), path(bai) val enable_sanitization output: - tuple val(sample_id), path("${sample_id}.sanitized.bam"), path("${sample_id}.sanitized.bam.bai"), emit: sanitized_bam_bai - path "versions.yml" , emit: versions + tuple val(anon_id), path("${anon_id}.sanitized.bam"), path("${anon_id}.sanitized.bam.bai"), emit: sanitized_bam_bai + path "versions.yml" , emit: versions script: if (enable_sanitization) { """ - # Strip PHI/patient-identifying comments and unneeded headers from BAM - samtools view -H ${bam} | grep -v -E "(@CO|SM:|DS:.*patient|DS:.*hospital)" > clean_header.sam || true - samtools reheader clean_header.sam ${bam} > ${sample_id}.sanitized.bam - samtools index ${sample_id}.sanitized.bam + # 1. Extract header and strip PHI, local paths, and hospital identifiers + samtools view -H ${bam} \\ + | grep -v -E "(@CO|DS:.*patient|DS:.*hospital|DS:.*barcode)" \\ + | sed "s/SM:${sample_id}/SM:${anon_id}/g" > clean_header.sam + + # 2. Reheader and index BAM + samtools reheader clean_header.sam ${bam} > ${anon_id}.sanitized.bam + samtools index -@ ${task.cpus ?: 2} ${anon_id}.sanitized.bam cat <<-END_VERSIONS > versions.yml "${task.process}": - samtools: \$( samtools --version | head -n1 | sed 's/samtools //' ) + samtools: \$( samtools --version 2>&1 | head -n1 | sed -e 's/samtools //' ) END_VERSIONS """ } else { """ - ln -s ${bam} ${sample_id}.sanitized.bam - ln -s ${bai} ${sample_id}.sanitized.bam.bai + ln -s ${bam} ${anon_id}.sanitized.bam + ln -s ${bai} ${anon_id}.sanitized.bam.bai cat <<-END_VERSIONS > versions.yml "${task.process}": - samtools: \$( samtools --version | head -n1 | sed 's/samtools //' ) + samtools: \$( samtools --version 2>&1 | head -n1 | sed -e 's/samtools //' ) END_VERSIONS """ } stub: """ - touch ${sample_id}.sanitized.bam - touch ${sample_id}.sanitized.bam.bai + touch ${anon_id}.sanitized.bam + touch ${anon_id}.sanitized.bam.bai + cat <<-END_VERSIONS > versions.yml + "${task.process}": + samtools: 1.23.1 + END_VERSIONS + """ +} + +process SANITIZE_VCF { + tag "$anon_id" + label 'process_single' + + conda "bioconda::bcftools=1.23.1" + container "quay.io/biocontainers/bcftools:1.23.1--hb2cee57_0" + + input: + tuple val(sample_id), val(anon_id), path(vcf), path(tbi) + val enable_sanitization + + output: + tuple val(anon_id), path("${anon_id}.sanitized.vcf.gz"), path("${anon_id}.sanitized.vcf.gz.tbi"), emit: sanitized_vcf_tbi + path "versions.yml" , emit: versions + + script: + if (enable_sanitization) { + """ + # 1. Create sample replacement map + echo "${sample_id} ${anon_id}" > sample_name_map.txt + + # 2. Extract and sanitize header comments (strip command lines revealing host paths) + bcftools view -h ${vcf} | grep -v -E "(##CL=|##commandline=|##bcftools_callCommand=|##contig.*URL=)" > clean_vcf_header.txt + + # 3. Reheader with clean headers and anonymized sample identifier + bcftools reheader -h clean_vcf_header.txt -s sample_name_map.txt ${vcf} -o ${anon_id}.sanitized.vcf.gz + bcftools index -t ${anon_id}.sanitized.vcf.gz + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + bcftools: \$( bcftools --version 2>&1 | head -n1 | sed -e 's/bcftools //' ) + END_VERSIONS + """ + } else { + """ + ln -s ${vcf} ${anon_id}.sanitized.vcf.gz + ln -s ${tbi} ${anon_id}.sanitized.vcf.gz.tbi + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + bcftools: \$( bcftools --version 2>&1 | head -n1 | sed -e 's/bcftools //' ) + END_VERSIONS + """ + } + stub: + """ + touch ${anon_id}.sanitized.vcf.gz + touch ${anon_id}.sanitized.vcf.gz.tbi cat <<-END_VERSIONS > versions.yml "${task.process}": - samtools: 1.19.2 + bcftools: 1.23.1 END_VERSIONS """ } process GENERATE_AUDIT_MANIFEST { - tag "$sample_id" + tag "$anon_id" label 'process_single' input: - tuple val(sample_id), path(artifacts) + tuple val(sample_id), val(anon_id), path(artifacts) + path ref_nuc + path input_checksums val workflow_params output: - tuple val(sample_id), path("${sample_id}_manifest.json"), emit: manifest - path "${sample_id}_checksums.sha256" , emit: checksums + tuple val(anon_id), path("${anon_id}_manifest.json"), emit: manifest + path "${anon_id}_checksums.sha256" , emit: checksums + path "sample_mapping.audit.tsv" , optional: true, emit: mapping_audit script: def artifacts_list = artifacts instanceof List ? artifacts.join(' ') : artifacts.toString() + def input_chk_list = input_checksums instanceof List ? input_checksums.join(' ') : input_checksums.toString() """ - # 1. Compute SHA-256 checksums for each output artifact - sha256sum ${artifacts_list} > ${sample_id}_checksums.sha256 + # 1. Compute reference FASTA checksum + REF_SHA=\$(sha256sum ${ref_nuc} | awk '{print \$1}') + + # 2. Extract input checksum recorded in Step 1 + INPUT_SHA=\$(cat ${input_chk_list} | awk '{print \$1}' | head -n1) - # 2. Build structured JSON audit record - cat <<-EOF > ${sample_id}_manifest.json + # 3. Compute SHA-256 checksums for each final output artifact + sha256sum ${artifacts_list} > ${anon_id}_checksums.sha256 + + # 4. Record restricted de-identification audit mapping + echo -e "${sample_id}\\t${anon_id}\\t\$(date -u +'%Y-%m-%dT%H:%M:%SZ')" > sample_mapping.audit.tsv + + # 5. Build comprehensive, publication-ready forensic JSON manifest + cat <<-EOF > ${anon_id}_manifest.json { "pipeline": "longvarmeth", "sample_id": "${sample_id}", + "anonymized_id": "${anon_id}", "timestamp_utc": "\$(date -u +'%Y-%m-%dT%H:%M:%SZ')", - "workflow_commit": "${workflow.commitId ?: 'dev'}", - "workflow_version": "${workflow.manifest.version ?: '1.0.0'}", + "workflow": { + "version": "${workflow.manifest.version ?: '1.0.0'}", + "commit": "${workflow.commitId ?: 'dev'}", + "container_engine": "${workflow.containerEngine ?: 'none'}", + "profile": "${workflow.profile ?: 'standard'}" + }, + "provenance": { + "input_checksum_sha256": "\${INPUT_SHA}", + "reference_fasta": "${ref_nuc.name}", + "reference_sha256": "\${REF_SHA}" + }, "parameters": { "platform": "${params.platform}", "species": "${params.species}", "run_type": "${params.run_type}", "snv_caller": "${params.snv_caller}", "sv_caller": "${params.sv_caller}", - "phasing_tool": "${params.phasing_tool}" + "phasing_tool": "${params.phasing_tool}", + "phi_sanitization_enabled": ${params.enable_phi_sanitization ? true : false} }, "artifacts": [ - \$(awk '{ printf "{\\"file\\": \\"%s\\", \\"sha256\\": \\"%s\\"},\\n", \$2, \$1 }' ${sample_id}_checksums.sha256 | sed '\$ s/,\$//') + \$(awk '{ printf "{\\"file\\": \\"%s\\", \\"sha256\\": \\"%s\\"},\\n", \$2, \$1 }' ${anon_id}_checksums.sha256 | sed '\$ s/,\$//') ] } EOF @@ -97,27 +180,77 @@ process GENERATE_AUDIT_MANIFEST { stub: """ - touch ${sample_id}_checksums.sha256 - echo '{"pipeline":"longvarmeth","sample_id":"${sample_id}"}' > ${sample_id}_manifest.json + touch ${anon_id}_checksums.sha256 + touch sample_mapping.audit.tsv + echo '{"pipeline":"longvarmeth","anonymized_id":"${anon_id}"}' > ${anon_id}_manifest.json """ } workflow CLINICAL_GOVERNANCE { take: - ch_bams // tuple(sample_id, bam, bai) - ch_final_outputs // tuple(sample_id, [files]) + ch_bams // tuple(sample_id, bam, bai) + ch_vcfs // tuple(sample_id, vcf, tbi) + ch_input_checksums // tuple(sample_id, input_sha256_file) + ref_nuc_path // path(reference_fasta) + ch_extra_artifacts // tuple(sample_id, [files]) main: ch_versions = Channel.empty() - SANITIZE_METADATA(ch_bams, params.enable_phi_sanitization ?: false) - ch_versions = ch_versions.mix(SANITIZE_METADATA.out.versions) + // 1. Generate deterministic anonymized identifier (anon_id) + def ch_sample_tuples = ch_bams + .map { sample_id, bam, bai -> + def anon_id = params.enable_phi_sanitization ? + "ANON_" + java.security.MessageDigest.getInstance("SHA-256").digest(sample_id.bytes).encodeHex().toString().substring(0, 10) : + sample_id + tuple(sample_id, anon_id, bam, bai) + } + + // 2. Sanitize BAM headers + SANITIZE_BAM(ch_sample_tuples, params.enable_phi_sanitization ?: false) + ch_versions = ch_versions.mix(SANITIZE_BAM.out.versions) + + // 3. Sanitize VCF headers and replace sample names + def ch_vcf_tuples = ch_vcfs + .map { sample_id, vcf, tbi -> + def anon_id = params.enable_phi_sanitization ? + "ANON_" + java.security.MessageDigest.getInstance("SHA-256").digest(sample_id.bytes).encodeHex().toString().substring(0, 10) : + sample_id + tuple(sample_id, anon_id, vcf, tbi) + } + SANITIZE_VCF(ch_vcf_tuples, params.enable_phi_sanitization ?: false) + ch_versions = ch_versions.mix(SANITIZE_VCF.out.versions) + + // 4. Build comprehensive forensic audit manifest + def ch_all_input_checksums = ch_input_checksums.map { id, file -> file }.collect() + + // Join sanitized BAM and VCF outputs by sample index + def ch_sanitized_pairs = ch_sample_tuples + .map { sample_id, anon_id, bam, bai -> tuple(anon_id, sample_id) } + .join(SANITIZE_BAM.out.sanitized_bam_bai) + .join(SANITIZE_VCF.out.sanitized_vcf_tbi) + .map { anon_id, sample_id, s_bam, s_bai, s_vcf, s_tbi -> + tuple(sample_id, anon_id, [s_bam, s_bai, s_vcf, s_tbi]) + } + + def ch_manifest_input = ch_sanitized_pairs + .join(ch_extra_artifacts, remainder: true) + .map { row -> + def sample_id = row[0] + def anon_id = row[1] + def artifacts = row[2] instanceof List ? row[2] : [row[2]] + def extra = (row.size() > 3 && row[3] != null) ? (row[3] instanceof List ? row[3] : [row[3]]) : [] + def all_artifacts = (artifacts + extra).flatten().findAll { it != null } + tuple(sample_id, anon_id, all_artifacts) + } - GENERATE_AUDIT_MANIFEST(ch_final_outputs, params) + GENERATE_AUDIT_MANIFEST(ch_manifest_input, ref_nuc_path, ch_all_input_checksums, params) emit: - sanitized_bams = SANITIZE_METADATA.out.sanitized_bam_bai - manifests = GENERATE_AUDIT_MANIFEST.out.manifest - checksums = GENERATE_AUDIT_MANIFEST.out.checksums - versions = ch_versions + sanitized_bams = SANITIZE_BAM.out.sanitized_bam_bai + sanitized_vcfs = SANITIZE_VCF.out.sanitized_vcf_tbi + manifests = GENERATE_AUDIT_MANIFEST.out.manifest + checksums = GENERATE_AUDIT_MANIFEST.out.checksums + mapping_audit = GENERATE_AUDIT_MANIFEST.out.mapping_audit + versions = ch_versions } diff --git a/subworkflows/local/methylation/main.nf b/subworkflows/local/methylation/main.nf index 674b11b..e0f5f53 100644 --- a/subworkflows/local/methylation/main.nf +++ b/subworkflows/local/methylation/main.nf @@ -1,7 +1,52 @@ // subworkflows/local/methylation/main.nf -// Step 7: Epigenomics and base modifications (modkit for ONT; pb-cpg-tools + methbat for PacBio) +// Step 7: Epigenomics and base modifications (modkit for ONT; pb-cpg-tools for PacBio HiFi) include { MODKIT_PILEUP as MOD_NUC } from '../../../modules/nf-core/modkit/pileup' +include { PBCPGTOOLS } from '../../../modules/nf-core/pbcpgtools' + +process CHECK_MOD_TAGS { + tag "$sample_id" + label 'process_single' + + conda "bioconda::samtools=1.23.1" + container "quay.io/biocontainers/samtools:1.23.1--ha83d96e_0" + + input: + tuple val(sample_id), path(bam), path(bai) + + output: + tuple val(sample_id), path("${sample_id}.mod_tags_qc.txt"), emit: qc_report + path "versions.yml" , emit: versions + + script: + """ + echo "=== Kinetic Modification Tag Check for ${sample_id} ===" > ${sample_id}.mod_tags_qc.txt + MM_COUNT=\$(samtools view ${bam} | head -n 1000 | grep -c "MM:Z:" || true) + ML_COUNT=\$(samtools view ${bam} | head -n 1000 | grep -c "ML:B:" || true) + echo "Sampled first 1000 alignments:" >> ${sample_id}.mod_tags_qc.txt + echo " Alignments with MM tag: \${MM_COUNT} / 1000" >> ${sample_id}.mod_tags_qc.txt + echo " Alignments with ML tag: \${ML_COUNT} / 1000" >> ${sample_id}.mod_tags_qc.txt + if [ "\${MM_COUNT}" -eq 0 ]; then + echo "[WARN] No MM base-modification tags detected in first 1000 reads of ${bam}!" >> ${sample_id}.mod_tags_qc.txt + else + echo "[INFO] Modification tags validated successfully." >> ${sample_id}.mod_tags_qc.txt + fi + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + samtools: \$( samtools --version 2>&1 | head -n1 | sed -e 's/samtools //' ) + END_VERSIONS + """ + + stub: + """ + touch ${sample_id}.mod_tags_qc.txt + cat <<-END_VERSIONS > versions.yml + "${task.process}": + samtools: 1.23.1 + END_VERSIONS + """ +} workflow METHYLATION { take: @@ -11,13 +56,28 @@ workflow METHYLATION { main: ch_versions = Channel.empty() def ch_bed_methylation = Channel.empty() + def ch_methylation_qc = Channel.empty() + + // 1. Tag validation QC + CHECK_MOD_TAGS(aligned_bam_bai) + ch_versions = ch_versions.mix(CHECK_MOD_TAGS.out.versions) + ch_methylation_qc = CHECK_MOD_TAGS.out.qc_report - // 1. Modkit Whole-Genome 5mC / 5hmC / 6mA Pileup - MOD_NUC(aligned_bam_bai, ref_nuc_path) - ch_versions = ch_versions.mix(MOD_NUC.out.versions) - ch_bed_methylation = MOD_NUC.out.bed + // 2. Platform-specialized Base Modification Analysis + if (params.platform == 'pacbio') { + // Native PacBio HiFi CpG aggregation with pb-cpg-tools + PBCPGTOOLS(aligned_bam_bai, ref_nuc_path) + ch_versions = ch_versions.mix(PBCPGTOOLS.out.versions) + ch_bed_methylation = PBCPGTOOLS.out.bed + } else { + // Oxford Nanopore modkit whole-genome 5mC / 5hmC pileup + MOD_NUC(aligned_bam_bai, ref_nuc_path) + ch_versions = ch_versions.mix(MOD_NUC.out.versions) + ch_bed_methylation = MOD_NUC.out.bed + } emit: bed_methylation = ch_bed_methylation + qc_report = ch_methylation_qc versions = ch_versions } diff --git a/subworkflows/local/phasing/main.nf b/subworkflows/local/phasing/main.nf index 74c77f7..1473c3e 100644 --- a/subworkflows/local/phasing/main.nf +++ b/subworkflows/local/phasing/main.nf @@ -1,8 +1,9 @@ // subworkflows/local/phasing/main.nf -// Step 8: Haplotype phasing and read haplotagging (WhatsHap / HiPhase) +// Step 8: Multi-variant joint co-phasing and read haplotagging (WhatsHap / HiPhase) include { WHATSHAP_PHASE } from '../../../modules/nf-core/whatshap/phase' include { WHATSHAP_HAPLOTAG } from '../../../modules/nf-core/whatshap/haplotag' +include { WHATSHAP_STATS } from '../../../modules/nf-core/whatshap/stats' include { HIPHASE } from '../../../modules/nf-core/hiphase' workflow PHASING { @@ -10,6 +11,7 @@ workflow PHASING { aligned_bam_bai // tuple(sample_id, bam, bai) small_vcf_tbi // tuple(sample_id, vcf, tbi) sv_vcf_tbi // tuple(sample_id, vcf, tbi) + trgt_vcf_tbi // tuple(sample_id, vcf, tbi) ref_nuc_path // path(reference_fasta) ref_fai_path // path(reference_fai) @@ -17,18 +19,34 @@ workflow PHASING { ch_versions = Channel.empty() def ch_phased_bam_bai = Channel.empty() def ch_phased_vcf = Channel.empty() + def ch_phased_sv_vcf = Channel.empty() + def ch_phasing_stats = Channel.empty() if (params.platform == 'pacbio' || params.phasing_tool == 'hiphase') { - // HiPhase Co-Phasing (SNVs + SVs + STRs) + // HiPhase Joint Multi-Variant Co-Phasing (SNVs + SVs + STRs) def hiphase_input = aligned_bam_bai .join(small_vcf_tbi) - .map { sample_id, bam, bai, vcf, tbi -> - tuple(sample_id, bam, bai, vcf, tbi, [], []) + .join(sv_vcf_tbi, remainder: true) + .join(trgt_vcf_tbi, remainder: true) + .map { row -> + def sample_id = row[0] + def bam = row[1] + def bai = row[2] + def vcf = row[3] + def tbi = row[4] + def sv_vcf = (row.size() > 5 && row[5] != null) ? row[5] : [] + def sv_tbi = (row.size() > 6 && row[6] != null) ? row[6] : [] + def str_vcf = (row.size() > 7 && row[7] != null) ? row[7] : [] + def str_tbi = (row.size() > 8 && row[8] != null) ? row[8] : [] + tuple(sample_id, bam, bai, vcf, tbi, sv_vcf, sv_tbi, str_vcf, str_tbi) } + HIPHASE(hiphase_input, ref_nuc_path) - ch_versions = ch_versions.mix(HIPHASE.out.versions) + ch_versions = ch_versions.mix(HIPHASE.out.versions) ch_phased_bam_bai = HIPHASE.out.phased_bam_bai ch_phased_vcf = HIPHASE.out.small_vcf_phased + ch_phased_sv_vcf = HIPHASE.out.sv_vcf_phased + ch_phasing_stats = HIPHASE.out.stats_csv } else { // WhatsHap Phasing & Read Haplotagging def ref_with_fai = [ref_nuc_path, ref_fai_path] @@ -38,7 +56,7 @@ workflow PHASING { tuple(sample_id, vcf, bam, bai) } WHATSHAP_PHASE(phase_input, ref_with_fai) - ch_versions = ch_versions.mix(WHATSHAP_PHASE.out.versions) + ch_versions = ch_versions.mix(WHATSHAP_PHASE.out.versions) ch_phased_vcf = WHATSHAP_PHASE.out.phased_vcf def haplotag_input = WHATSHAP_PHASE.out.phased_vcf @@ -47,12 +65,19 @@ workflow PHASING { tuple(sample_id, vcf, tbi, bam, bai) } WHATSHAP_HAPLOTAG(haplotag_input, ref_with_fai) - ch_versions = ch_versions.mix(WHATSHAP_HAPLOTAG.out.versions) + ch_versions = ch_versions.mix(WHATSHAP_HAPLOTAG.out.versions) ch_phased_bam_bai = WHATSHAP_HAPLOTAG.out.haplotagged_bam_bai + + // Phasing metrics and block statistics + WHATSHAP_STATS(WHATSHAP_PHASE.out.phased_vcf) + ch_versions = ch_versions.mix(WHATSHAP_STATS.out.versions) + ch_phasing_stats = WHATSHAP_STATS.out.stats_tsv } emit: phased_bam_bai = ch_phased_bam_bai phased_vcf = ch_phased_vcf + phased_sv_vcf = ch_phased_sv_vcf + phasing_stats = ch_phasing_stats versions = ch_versions } diff --git a/subworkflows/local/reporting/main.nf b/subworkflows/local/reporting/main.nf index 1ecdfa9..ebbbecf 100644 --- a/subworkflows/local/reporting/main.nf +++ b/subworkflows/local/reporting/main.nf @@ -3,6 +3,7 @@ include { MULTIQC } from '../../../modules/nf-core/multiqc' include { DUMP_MQC_METADATA } from '../../../modules/local/dump_mqc_metadata' +include { EXECUTION_STATUS_MATRIX } from '../../../modules/local/execution_status_matrix' workflow REPORTING { take: @@ -16,12 +17,30 @@ workflow REPORTING { def methods_template = file("${projectDir}/assets/methods_description.yml") DUMP_MQC_METADATA(methods_template) - // 2. MultiQC Dashboard + // 2. Generate Transparent Analytical Stage Execution Matrix + EXECUTION_STATUS_MATRIX( + params.run_type, + params.platform, + params.snv_caller, + params.sv_caller, + params.phasing_tool, + params.skip_alignment, + params.skip_variants, + params.skip_methylation, + params.enable_phasing, + params.run_benchmarking, + params.enable_phi_sanitization, + params.enable_audit_trail + ) + ch_versions = ch_versions.mix(EXECUTION_STATUS_MATRIX.out.versions) + + // 3. MultiQC Dashboard def multiqc_files = Channel.empty() .concat(qc_files) .concat(DUMP_MQC_METADATA.out.summary) .concat(DUMP_MQC_METADATA.out.versions) .concat(DUMP_MQC_METADATA.out.methods) + .concat(EXECUTION_STATUS_MATRIX.out.tsv) .concat(versions) .collect() diff --git a/subworkflows/local/structural_variants/main.nf b/subworkflows/local/structural_variants/main.nf index 704318d..bee0788 100644 --- a/subworkflows/local/structural_variants/main.nf +++ b/subworkflows/local/structural_variants/main.nf @@ -44,8 +44,9 @@ workflow STRUCTURAL_VARIANTS { } emit: - sv_vcf = ch_sv_vcf - cnv_calls= ch_cnv_calls - trgt_vcf = ch_trgt_vcf - versions = ch_versions + sv_vcf_tbi = SNIFFLES.out.sv_vcf_tbi + sv_vcf = ch_sv_vcf + cnv_calls = ch_cnv_calls + trgt_vcf = ch_trgt_vcf + versions = ch_versions } diff --git a/subworkflows/local/variant_calling/main.nf b/subworkflows/local/variant_calling/main.nf index e30269f..2c9e749 100644 --- a/subworkflows/local/variant_calling/main.nf +++ b/subworkflows/local/variant_calling/main.nf @@ -1,8 +1,10 @@ // subworkflows/local/variant_calling/main.nf -// Step 5: Small variant calling (SNVs and InDels) +// Step 5: Small variant calling (SNVs and InDels) with modular caller dispatch include { BCFTOOLS_CALL as CALL_NUC } from '../../../modules/nf-core/bcftools/call' include { BCFTOOLS_CONCAT } from '../../../modules/nf-core/bcftools/concat' +include { DEEPVARIANT } from '../../../modules/nf-core/deepvariant' +include { CLAIR3 } from '../../../modules/nf-core/clair3' workflow VARIANT_CALLING { take: @@ -12,36 +14,50 @@ workflow VARIANT_CALLING { main: ch_versions = Channel.empty() - - // 1. Generate chromosome calling partitions - def ch_chroms = Channel.fromPath(ref_fai_path) - .splitCsv(sep: '\t') - .map { row -> row[0] } - .filter { chrom -> - if (params.species == 'human') { - return chrom =~ /^chr[0-9XYM]+$/ || chrom =~ /^[0-9XYM]+$/ + def ch_vcf_tbi = Channel.empty() + + if (params.snv_caller == 'deepvariant') { + // High-precision DeepVariant calling with chemistry-specific neural network model + def model_type = params.platform == 'pacbio' ? 'PACBIO' : 'ONT_R104' + DEEPVARIANT(aligned_bam_bai, ref_nuc_path, ref_fai_path, model_type) + ch_versions = ch_versions.mix(DEEPVARIANT.out.versions) + ch_vcf_tbi = DEEPVARIANT.out.vcf_tbi + } else if (params.snv_caller == 'clair3') { + // Clair3 neural network long-read variant caller + def clair3_plat = params.platform == 'pacbio' ? 'hifi' : 'ont' + CLAIR3(aligned_bam_bai, ref_nuc_path, ref_fai_path, clair3_plat) + ch_versions = ch_versions.mix(CLAIR3.out.versions) + ch_vcf_tbi = CLAIR3.out.vcf_tbi + } else { + // Fallback: Parallel chromosome calling with BCFtools mpileup/call + def ch_chroms = Channel.fromPath(ref_fai_path) + .splitCsv(sep: '\t') + .map { row -> row[0] } + .filter { chrom -> + if (params.species == 'human') { + return chrom =~ /^chr[0-9XYM]+$/ || chrom =~ /^[0-9XYM]+$/ + } + return true } - return true - } - def call_input = aligned_bam_bai.combine(ch_chroms) + def call_input = aligned_bam_bai.combine(ch_chroms) - // 2. Parallel small variant calling - CALL_NUC(call_input, ref_nuc_path) - ch_versions = ch_versions.mix(CALL_NUC.out.versions) + CALL_NUC(call_input, ref_nuc_path) + ch_versions = ch_versions.mix(CALL_NUC.out.versions) - // 3. Genome-wide concatenation & indexing - def concat_input = CALL_NUC.out.vcf_tbi - .groupTuple(by: 0) - .map { sample_id, vcfs, tbis -> - def sorted = [vcfs, tbis].transpose().sort { a, b -> a[0].name <=> b[0].name } - tuple(sample_id, sorted.collect { it[0] }, sorted.collect { it[1] }) - } + def concat_input = CALL_NUC.out.vcf_tbi + .groupTuple(by: 0) + .map { sample_id, vcfs, tbis -> + def sorted = [vcfs, tbis].transpose().sort { a, b -> a[0].name <=> b[0].name } + tuple(sample_id, sorted.collect { it[0] }, sorted.collect { it[1] }) + } - BCFTOOLS_CONCAT(concat_input) - ch_versions = ch_versions.mix(BCFTOOLS_CONCAT.out.versions) + BCFTOOLS_CONCAT(concat_input) + ch_versions = ch_versions.mix(BCFTOOLS_CONCAT.out.versions) + ch_vcf_tbi = BCFTOOLS_CONCAT.out.vcf_tbi + } emit: - vcf_tbi = BCFTOOLS_CONCAT.out.vcf_tbi + vcf_tbi = ch_vcf_tbi versions = ch_versions } diff --git a/tests/data/giab/hg002_benchmark_regions.bed b/tests/data/giab/hg002_benchmark_regions.bed new file mode 100644 index 0000000..3117ed9 --- /dev/null +++ b/tests/data/giab/hg002_benchmark_regions.bed @@ -0,0 +1 @@ +chr1 0 1000000 diff --git a/tests/data/giab/hg002_benchmark_sv_regions.bed b/tests/data/giab/hg002_benchmark_sv_regions.bed new file mode 100644 index 0000000..3117ed9 --- /dev/null +++ b/tests/data/giab/hg002_benchmark_sv_regions.bed @@ -0,0 +1 @@ +chr1 0 1000000 diff --git a/tests/data/giab/hg002_truth_smallvar.vcf.gz b/tests/data/giab/hg002_truth_smallvar.vcf.gz new file mode 100644 index 0000000000000000000000000000000000000000..2af5ab961b620678d6fd884e905325629fa299e9 GIT binary patch literal 206 zcmV;<05Sg`iwFp4YUFAF188S3Ffw0sa&>fQUvq6?Y;1O6axQjbW&kCQO$&oC7=zER zze4mXuFBYH9Tc@H=ybK)U0nTuPQ{k}{#BP-AWst4?I$VvW&5-mzasiQ`=RB!G%t9< z@HLNdO`67wh3d9x)zdO#he&A~N{da|YpaxaJ2%60%dDs2V7hS&JSpZuk2pYwOd`ry zRYF}gkjKCh3Z&rhc#;DCbCBO@1+-!%;gF>Wh1OmKrU85)kirGQe#f|!13CrP5BHq> I?7sj20RLiOi2wiq literal 0 HcmV?d00001 diff --git a/tests/data/giab/hg002_truth_smallvar.vcf.gz.tbi b/tests/data/giab/hg002_truth_smallvar.vcf.gz.tbi new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/giab/hg002_truth_sv.vcf.gz b/tests/data/giab/hg002_truth_sv.vcf.gz new file mode 100644 index 0000000000000000000000000000000000000000..66e1dde60dc600b3263c55a2666b5f38979ef41a GIT binary patch literal 305 zcmV-10nYv(iwFp4YUFAF188S3Ffw0sa&>fQUvqXYc4KA$jgU=`f-n$<&%>{1;L5ti zEPK(K5GhK6z(Ut#k1Q5!f`yc7vj2W5p7iA0=Hq>4=5?G#biC=+rPAcMjcy-{rR6wL zoG@CDbsUmBC`&iwW39xXMK6gRbRC*hnyV2F)j0B#y_qz1FSTfAO?_6L+i}_IGx6{o zW+G0bZ0?sw_LEaLyZV4Qrl0!gr-lt zSZdyK^$1Mnqh3MMG<*d}(wA1D_a-?{GJYOtsbgB6;)iYPyX0K876w{O(!*=q9CU!vO#Q D-uaM( literal 0 HcmV?d00001 diff --git a/tests/data/giab/hg002_truth_sv.vcf.gz.tbi b/tests/data/giab/hg002_truth_sv.vcf.gz.tbi new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/ref_nuc.fasta b/tests/data/ref_nuc.fasta index e69de29..4de9daa 100644 --- a/tests/data/ref_nuc.fasta +++ b/tests/data/ref_nuc.fasta @@ -0,0 +1,2 @@ +>chr1 +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC diff --git a/tests/data/ref_nuc.fasta.fai b/tests/data/ref_nuc.fasta.fai new file mode 100644 index 0000000..f5e32a9 --- /dev/null +++ b/tests/data/ref_nuc.fasta.fai @@ -0,0 +1 @@ +chr1 100 6 100 101 diff --git a/tests/pipeline/negative_tests.nf.test b/tests/pipeline/negative_tests.nf.test new file mode 100644 index 0000000..2fe84b8 --- /dev/null +++ b/tests/pipeline/negative_tests.nf.test @@ -0,0 +1,37 @@ +nextflow_pipeline { + + name "Test Pipeline - Negative & Fail-Fast Validation Tests" + script "main.nf" + + test("Should abort when --ref_nuc is missing in reference-based mode") { + when { + params { + platform = "ont" + input = "tests/samplesheet_test.csv" + outdir = "results_test_neg1" + run_type = "reference_based" + ref_nuc = "" + } + } + then { + assert workflow.failed + assert workflow.exitStatus != 0 + } + } + + test("Should abort when PacBio input format is provided to ONT workflow") { + when { + params { + platform = "ont" + input = "tests/csv/samplesheet_pacbio_test.csv" + outdir = "results_test_neg2" + } + } + then { + assert workflow.failed + assert workflow.exitStatus != 0 + } + } + +} + diff --git a/tests/run_negative_tests.sh b/tests/run_negative_tests.sh new file mode 100755 index 0000000..259f017 --- /dev/null +++ b/tests/run_negative_tests.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# tests/run_negative_tests.sh +# Automated regression test suite asserting expected failure codes and diagnostics for negative test cases. + +set -euo pipefail + +NF_BIN="/opt/homebrew/bin/nextflow" +if ! command -v "$NF_BIN" &> /dev/null; then + NF_BIN="nextflow" +fi + +echo "==========================================================" +echo "Running LongVarMeth Negative Test Suite" +echo "==========================================================" + +FAILED_TESTS=0 +TOTAL_TESTS=0 + +run_neg_test() { + local test_name="$1" + local expected_pattern="$2" + shift 2 + local cmd=("$@") + + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + echo -n "Test $TOTAL_TESTS: $test_name ... " + + local output + set +e + output=$("${cmd[@]}" 2>&1) + local status=$? + set -e + + if [ "$status" -eq 0 ]; then + echo "FAILED (Expected non-zero exit code, got 0)" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return + fi + + if echo "$output" | grep -q "$expected_pattern"; then + echo "PASSED (exit $status, matched '$expected_pattern')" + else + echo "FAILED (Expected pattern '$expected_pattern' not found in output)" + echo "$output" + FAILED_TESTS=$((FAILED_TESTS + 1)) + fi +} + +# Test 1: Missing --ref_nuc in reference-based mode +run_neg_test \ + "Missing --ref_nuc in reference_based mode" \ + "Reference FASTA file specified by --ref_nuc does not exist" \ + "$NF_BIN" run main.nf -profile test -stub --run_type reference_based --ref_nuc "" + +# Test 2: Platform mismatch (PacBio BAM on --platform ont) +cat < tests/_tmp_mismatch.csv +run_id,input_path,input_type,kit,model,modified_bases_model +mismatch_sample,tests/data/sample.pacbio.bam,pacbio_bam,,, +EOF + +run_neg_test \ + "Platform mismatch rejection (PacBio BAM with --platform ont)" \ + "PacBio format 'pacbio_bam' supplied to ONT workflow" \ + "$NF_BIN" run main.nf -profile test -stub --platform ont --input tests/_tmp_mismatch.csv + +rm -f tests/_tmp_mismatch.csv + +# Test 3: Missing required column in samplesheet +cat < tests/_tmp_missing_col.csv +run_id,input_path +sample1,tests/data/reads.pod5 +EOF + +run_neg_test \ + "Missing required column in samplesheet" \ + "missing required column 'input_type'" \ + "$NF_BIN" run main.nf -profile test -stub --input tests/_tmp_missing_col.csv + +rm -f tests/_tmp_missing_col.csv + +echo "==========================================================" +if [ "$FAILED_TESTS" -eq 0 ]; then + echo "ALL $TOTAL_TESTS NEGATIVE TESTS PASSED SUCCESSFULLY" + echo "==========================================================" + exit 0 +else + echo "$FAILED_TESTS / $TOTAL_TESTS TESTS FAILED" + echo "==========================================================" + exit 1 +fi + diff --git a/tests/unit_tests/test_pipeline_unit.py b/tests/unit_tests/test_pipeline_unit.py index d2162e0..e79c237 100644 --- a/tests/unit_tests/test_pipeline_unit.py +++ b/tests/unit_tests/test_pipeline_unit.py @@ -3,8 +3,8 @@ import csv import json import tempfile +import unittest from pathlib import Path -import pytest # Ensure bin/ is on Python path REPO_ROOT = Path(__file__).resolve().parent.parent.parent @@ -13,7 +13,7 @@ from generate_methylation_dashboard import parse_methbat_tsv, calculate_stats -class TestSamplesheetIngestion: +class TestSamplesheetIngestion(unittest.TestCase): """Validate samplesheet format, column requirements, and URI schemes.""" REQUIRED_HEADERS = ["run_id", "input_path", "input_type", "kit", "model", "modified_bases_model"] @@ -21,31 +21,35 @@ class TestSamplesheetIngestion: def test_pacbio_samplesheet_structure(self): csv_path = REPO_ROOT / "tests" / "csv" / "samplesheet_pacbio_test.csv" - assert csv_path.exists(), "PacBio test samplesheet must exist" + self.assertTrue(csv_path.exists(), "PacBio test samplesheet must exist") with open(csv_path, "r", encoding="utf-8") as f: reader = csv.DictReader(f) - assert reader.fieldnames == self.REQUIRED_HEADERS + self.assertEqual(reader.fieldnames, self.REQUIRED_HEADERS) rows = list(reader) - assert len(rows) >= 1 + self.assertGreaterEqual(len(rows), 1) for row in rows: - assert row["input_type"] in self.VALID_INPUT_TYPES - assert row["input_path"].startswith("http://") or row["input_path"].startswith("https://") or Path(row["input_path"]).name.endswith(".bam") + self.assertIn(row["input_type"], self.VALID_INPUT_TYPES) + self.assertTrue( + row["input_path"].startswith("http://") or + row["input_path"].startswith("https://") or + Path(row["input_path"]).name.endswith(".bam") + ) def test_nanopore_samplesheet_structure(self): csv_path = REPO_ROOT / "tests" / "samplesheet_test.csv" - assert csv_path.exists(), "Nanopore test samplesheet must exist" + self.assertTrue(csv_path.exists(), "Nanopore test samplesheet must exist") with open(csv_path, "r", encoding="utf-8") as f: reader = csv.DictReader(f) - assert reader.fieldnames == self.REQUIRED_HEADERS + self.assertEqual(reader.fieldnames, self.REQUIRED_HEADERS) rows = list(reader) - assert len(rows) >= 1 + self.assertGreaterEqual(len(rows), 1) for row in rows: - assert row["input_type"] in self.VALID_INPUT_TYPES + self.assertIn(row["input_type"], self.VALID_INPUT_TYPES) -class TestMethylationDashboardGenerator: +class TestMethylationDashboardGenerator(unittest.TestCase): """Validate parser, calculations, and HTML payload injection for the clinical dashboard.""" def test_parse_methbat_tsv_with_mock_data(self): @@ -58,17 +62,17 @@ def test_parse_methbat_tsv_with_mock_data(self): try: rows = parse_methbat_tsv(temp_tsv) - assert len(rows) == 2 - assert rows[0]["cpg_label"] == "H19-DMR" - assert rows[0]["qc_status"] == 0 # PASS - assert rows[1]["qc_status"] == 1 # Warning - assert float(rows[0]["mean_combined_methyl"]) == 0.520 - assert float(rows[0]["mean_hap1_methyl"]) == 0.980 + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["cpg_label"], "H19-DMR") + self.assertEqual(rows[0]["qc_status"], 0) # PASS + self.assertEqual(rows[1]["qc_status"], 1) # Warning + self.assertEqual(float(rows[0]["mean_combined_methyl"]), 0.520) + self.assertEqual(float(rows[0]["mean_hap1_methyl"]), 0.980) stats = calculate_stats(rows, "test_sample") - assert stats["passCount"] == 1 - assert stats["cov"] == "Phased-5mC" - assert "96.5%" in stats["h1Avg"] # Average of 98.0 and 95.0 + self.assertEqual(stats["passCount"], 1) + self.assertEqual(stats["cov"], "Phased-5mC") + self.assertIn("96.5%", stats["h1Avg"]) finally: if temp_tsv.exists(): os.remove(temp_tsv) @@ -81,27 +85,59 @@ def test_parse_empty_tsv_fallback(self): try: rows = parse_methbat_tsv(temp_tsv) - assert rows == [] + self.assertEqual(rows, []) stats = calculate_stats(rows, "empty_sample") - assert stats["passCount"] == 0 - assert stats["cov"] == "N/A" + self.assertEqual(stats["passCount"], 0) + self.assertEqual(stats["cov"], "N/A") finally: if temp_tsv.exists(): os.remove(temp_tsv) -class TestConfigAndModuleIntegrity: - """Validate nextflow configs and subworkflow bindings.""" +class TestConfigAndModuleIntegrity(unittest.TestCase): + """Validate nextflow configs, GIAB benchmarking profile, and subworkflow bindings.""" def test_nextflow_profiles_exist(self): nf_config = REPO_ROOT / "nextflow.config" content = nf_config.read_text() - for expected_profile in ["test_ont", "test_pacbio", "human", "eukaryote", "prokaryote", "virus", "fraka", "version_lock"]: - assert expected_profile in content, f"Profile '{expected_profile}' missing in nextflow.config" + for expected_profile in ["test_ont", "test_pacbio", "test_giab", "human", "eukaryote", "prokaryote", "virus", "fraka", "version_lock"]: + self.assertIn(expected_profile, content, f"Profile '{expected_profile}' missing in nextflow.config") def test_assets_dashboard_template_present(self): template = REPO_ROOT / "assets" / "methylation_dashboard" / "dashboard-template.html" - assert template.exists(), "dashboard-template.html must be present in assets" + self.assertTrue(template.exists(), "dashboard-template.html must be present in assets") content = template.read_text(errors="ignore") - assert "{{SAMPLE_DATA_JSON}}" in content - assert "{{SAMPLE_STATS_JSON}}" in content + self.assertIn("{{SAMPLE_DATA_JSON}}", content) + self.assertIn("{{SAMPLE_STATS_JSON}}", content) + + def test_multiqc_config_contains_execution_status(self): + mqc_config = REPO_ROOT / "assets" / "multiqc_config.yml" + self.assertTrue(mqc_config.exists(), "multiqc_config.yml must exist") + content = mqc_config.read_text() + self.assertIn("pipeline_execution_status", content) + + +class TestTruthSetBenchmarkingIntegrity(unittest.TestCase): + """Verify presence and validity of benchmark truth files and configuration.""" + + def test_giab_truth_files_present(self): + giab_dir = REPO_ROOT / "tests" / "data" / "giab" + self.assertTrue(giab_dir.exists(), "GIAB data directory must exist") + self.assertTrue((giab_dir / "hg002_truth_smallvar.vcf.gz").exists()) + self.assertTrue((giab_dir / "hg002_truth_smallvar.vcf.gz.tbi").exists()) + self.assertTrue((giab_dir / "hg002_benchmark_regions.bed").exists()) + self.assertTrue((giab_dir / "hg002_truth_sv.vcf.gz").exists()) + self.assertTrue((giab_dir / "hg002_truth_sv.vcf.gz.tbi").exists()) + self.assertTrue((giab_dir / "hg002_benchmark_sv_regions.bed").exists()) + + def test_giab_config_properties(self): + giab_cfg = REPO_ROOT / "conf" / "tests" / "test_giab.config" + self.assertTrue(giab_cfg.exists(), "test_giab.config must exist") + content = giab_cfg.read_text() + self.assertIn("run_benchmarking", content) + self.assertIn("benchmark_truth_vcf", content) + self.assertIn("benchmark_sv_truth_vcf", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflows/longvarmeth.nf b/workflows/longvarmeth.nf index 5e7f034..9cbaca1 100644 --- a/workflows/longvarmeth.nf +++ b/workflows/longvarmeth.nf @@ -17,6 +17,8 @@ include { VISUALIZATION } from '../subworkflows/local/visualization' include { REPORTING } from '../subworkflows/local/reporting' include { MERGE_BAMS } from '../modules/local/merge_bams' include { MOSDEPTH } from '../modules/nf-core/mosdepth' +include { SAMTOOLS_INDEX } from '../modules/nf-core/samtools/index' +include { BENCHMARKING } from '../subworkflows/local/benchmarking' workflow LONGVARMETH { // Check samplesheet exists @@ -47,34 +49,43 @@ workflow LONGVARMETH { // ========================================================================= INPUT_CHECK(samples_ch) ch_versions = ch_versions.mix(INPUT_CHECK.out.versions) + def ch_input_checksums = INPUT_CHECK.out.checksums - // Separate raw instrument files from pre-existing BAMs - def raw_runs = samples_ch.filter { it[2] == 'pod5' || it[2] == 'fast5' } - def direct_bam_runs = samples_ch.filter { it[2] in ['bam', 'pacbio_bam', 'pacbio_hifi', 'hifi', 'ubam'] } + // Branch input samples by input type + def ch_branched = samples_ch.branch { + raw_signal: it[2] in ['pod5', 'fast5'] + unaligned: it[2] in ['bam', 'pacbio_bam', 'pacbio_hifi', 'hifi', 'ubam', 'unaligned_bam'] + aligned: it[2] == 'aligned_bam' + fastq: it[2] in ['fastq', 'fastq.gz', 'fq', 'fq.gz'] + } // ========================================================================= // STEP 3 (Raw ONT): Host GPU Basecalling & Demultiplexing // ========================================================================= - BASECALL_DEMUX(raw_runs) + BASECALL_DEMUX(ch_branched.raw_signal) def basecalled_bams = BASECALL_DEMUX.out.demux_bams ch_versions = ch_versions.mix(BASECALL_DEMUX.out.versions) - // Format and merge direct input BAMs - def direct_bams_raw = direct_bam_runs.map { run_id, input_path, input_type, kit, model, modified_bases_model -> + // Format and merge direct input unaligned BAMs + def direct_bams_raw = ch_branched.unaligned.map { run_id, input_path, input_type, kit, model, modified_bases_model -> tuple(run_id, file(input_path)) } MERGE_BAMS(direct_bams_raw) ch_versions = ch_versions.mix(MERGE_BAMS.out.versions) def direct_bams = MERGE_BAMS.out.bam - def all_demux_bams = basecalled_bams.concat(direct_bams) + def all_demux_bams = basecalled_bams.mix(direct_bams) // ========================================================================= // STEP 2: Pre-Alignment Read QC (FastQC, Cramino, NanoPlot) // ========================================================================= READ_QC(all_demux_bams) ch_versions = ch_versions.mix(READ_QC.out.versions) - def fastq_ch = READ_QC.out.fastq + def fastq_ch = READ_QC.out.fastq.mix( + ch_branched.fastq.map { run_id, input_path, input_type, kit, model, mod_model -> + tuple(run_id, file(input_path)) + } + ) ch_qc_files = ch_qc_files .concat(READ_QC.out.fastqc_files) .concat(READ_QC.out.nanoplot_files) @@ -96,35 +107,56 @@ workflow LONGVARMETH { // ========================================================================= // REFERENCE-GUIDED BRANCH (Steps 4 - 11) // ========================================================================= - if (params.run_type == 'reference_based' && params.ref_nuc) { + if (params.run_type == 'reference_based') { def ref_nuc_path = file(params.ref_nuc) def ref_fai_path = file("${params.ref_nuc}.fai") - // --------------------------------------------------------------------- - // STEP 4: Reference Alignment & Kinetic Tag Preservation (pbmm2 / minimap2) - // --------------------------------------------------------------------- - def aligned_bams = Channel.empty() - def pre_aligned_runs = samples_ch.filter { it[2] == 'aligned_bam' } - def need_alignment_runs = samples_ch.filter { it[2] != 'aligned_bam' } + // Pre-aligned BAMs: verify index existence or auto-generate with SAMTOOLS_INDEX + def ch_prealigned_check = ch_branched.aligned + .map { run_id, input_path, input_type, kit, model, mod_model -> + def bamFile = file(input_path) + def baiFile = file("${input_path}.bai") + def altBai = file(input_path.replaceAll(/\.bam$/, '.bai')) + def csiFile = file("${input_path}.csi") + def hasIdx = baiFile.exists() || altBai.exists() || csiFile.exists() + def resolvedBai = baiFile.exists() ? baiFile : (altBai.exists() ? altBai : (csiFile.exists() ? csiFile : null)) + tuple(run_id, bamFile, resolvedBai, hasIdx) + } + .branch { + has_index: it[3] == true + needs_index: it[3] == false + } - if (pre_aligned_runs) { - def pre_aligned_bams = pre_aligned_runs.map { run_id, input_path, input_type, kit, model, mod_model -> - def bam_file = file(input_path) - def bai_file = file("${input_path}.bai") - if (!bai_file.exists()) { - bai_file = file(input_path.replaceAll(/\.bam$/, '.bai')) - } - tuple(run_id, bam_file, bai_file) + def ch_prealigned_ready = ch_prealigned_check.has_index + .map { run_id, bam, bai, hasIdx -> + tuple(run_id, bam, bai, 'pre_aligned') } - aligned_bams = aligned_bams.mix(pre_aligned_bams) - } - if (need_alignment_runs && !params.skip_alignment) { + SAMTOOLS_INDEX(ch_prealigned_check.needs_index.map { run_id, bam, bai, hasIdx -> tuple(run_id, bam) }) + ch_versions = ch_versions.mix(SAMTOOLS_INDEX.out.versions) + def ch_prealigned_indexed = SAMTOOLS_INDEX.out.bam_bai + .map { run_id, bam, bai -> + tuple(run_id, bam, bai, 'pre_aligned_indexed') + } + + def ch_all_prealigned = ch_prealigned_ready.mix(ch_prealigned_indexed) + + // --------------------------------------------------------------------- + // STEP 4: Reference Alignment & Kinetic Tag Preservation (pbmm2 / minimap2) + // --------------------------------------------------------------------- + def ch_newly_aligned = Channel.empty() + if (!params.skip_alignment) { ALIGNMENT(fastq_ch, all_demux_bams, ref_nuc_path) ch_versions = ch_versions.mix(ALIGNMENT.out.versions) - aligned_bams = aligned_bams.mix(ALIGNMENT.out.aligned_bam_bai) + ch_newly_aligned = ALIGNMENT.out.aligned_bam_bai + .map { run_id, bam, bai -> + tuple(run_id, bam, bai, 'pipeline_aligned') + } } + def ch_all_aligned_with_prov = ch_all_prealigned.mix(ch_newly_aligned) + def aligned_bams = ch_all_aligned_with_prov.map { run_id, bam, bai, prov -> tuple(run_id, bam, bai) } + // Post-alignment Coverage QC (Mosdepth) MOSDEPTH(aligned_bams, ref_nuc_path) ch_versions = ch_versions.mix(MOSDEPTH.out.versions) @@ -146,10 +178,14 @@ workflow LONGVARMETH { // STEP 6: Structural Variants, CNVs & Repeat Expansions (Sniffles2 / TRGT / HiFiCNV) // --------------------------------------------------------------------- def sv_vcfs = Channel.empty() + def sv_vcfs_tbi = Channel.empty() + def trgt_vcfs_tbi = Channel.empty() if (!params.skip_variants) { STRUCTURAL_VARIANTS(aligned_bams, ref_nuc_path) - ch_versions = ch_versions.mix(STRUCTURAL_VARIANTS.out.versions) - sv_vcfs = STRUCTURAL_VARIANTS.out.sv_vcf + ch_versions = ch_versions.mix(STRUCTURAL_VARIANTS.out.versions) + sv_vcfs = STRUCTURAL_VARIANTS.out.sv_vcf + sv_vcfs_tbi = STRUCTURAL_VARIANTS.out.sv_vcf_tbi + trgt_vcfs_tbi = STRUCTURAL_VARIANTS.out.trgt_vcf } // --------------------------------------------------------------------- @@ -158,20 +194,22 @@ workflow LONGVARMETH { def bed_methylation = Channel.empty() if (!params.skip_methylation) { METHYLATION(aligned_bams, ref_nuc_path) - ch_versions = ch_versions.mix(METHYLATION.out.versions) + ch_versions = ch_versions.mix(METHYLATION.out.versions) bed_methylation = METHYLATION.out.bed_methylation + ch_qc_files = ch_qc_files.mix(METHYLATION.out.qc_report.map { id, txt -> txt }) } // --------------------------------------------------------------------- - // STEP 8: Haplotype Phasing (HiPhase / WhatsHap) + // STEP 8: Multi-Variant Joint Co-Phasing (HiPhase / WhatsHap) // --------------------------------------------------------------------- def phased_bams = aligned_bams def phased_vcfs = small_vcfs if (!params.skip_variants && (params.species == 'human' || params.enable_phasing)) { - PHASING(aligned_bams, small_vcfs, sv_vcfs, ref_nuc_path, ref_fai_path) - ch_versions = ch_versions.mix(PHASING.out.versions) - phased_bams = PHASING.out.phased_bam_bai - phased_vcfs = PHASING.out.phased_vcf + PHASING(aligned_bams, small_vcfs, sv_vcfs_tbi, trgt_vcfs_tbi, ref_nuc_path, ref_fai_path) + ch_versions = ch_versions.mix(PHASING.out.versions) + phased_bams = PHASING.out.phased_bam_bai + phased_vcfs = PHASING.out.phased_vcf + ch_qc_files = ch_qc_files.mix(PHASING.out.phasing_stats.map { id, stats -> stats }) } // --------------------------------------------------------------------- @@ -185,15 +223,39 @@ workflow LONGVARMETH { } // --------------------------------------------------------------------- - // STEP 10: Clinical Governance & Forensic Audit Trail (milou pattern) + // STEP 10: Empirical Truth-Set Benchmarking (GIAB HG002) + // --------------------------------------------------------------------- + if (params.run_benchmarking) { + def truth_snv_vcf = params.benchmark_truth_vcf ? file(params.benchmark_truth_vcf) : null + def truth_snv_bed = params.benchmark_truth_bed ? file(params.benchmark_truth_bed) : null + def truth_sv_vcf = params.benchmark_sv_truth_vcf ? file(params.benchmark_sv_truth_vcf) : null + def truth_sv_bed = params.benchmark_sv_truth_bed ? file(params.benchmark_sv_truth_bed) : null + + BENCHMARKING( + phased_vcfs, + sv_vcfs_tbi, + ref_nuc_path, + ref_fai_path, + truth_snv_vcf, + truth_snv_bed, + truth_sv_vcf, + truth_sv_bed + ) + ch_versions = ch_versions.mix(BENCHMARKING.out.versions) + ch_qc_files = ch_qc_files.mix(BENCHMARKING.out.qc_files) + } + + // --------------------------------------------------------------------- + // STEP 11: Clinical Governance & Forensic Audit Trail (milou pattern) // --------------------------------------------------------------------- + def ch_sanitized_bams = phased_bams + def ch_sanitized_vcfs = final_vcfs if (params.enable_audit_trail || params.enable_phi_sanitization) { - def audit_targets = aligned_bams - .map { sample_id, bam, bai -> - tuple(sample_id, [bam, bai]) - } - CLINICAL_GOVERNANCE(aligned_bams, audit_targets) - ch_versions = ch_versions.mix(CLINICAL_GOVERNANCE.out.versions) + def extra_artifacts = bed_methylation.map { id, bed -> tuple(id, [bed]) } + CLINICAL_GOVERNANCE(phased_bams, final_vcfs, ch_input_checksums, ref_nuc_path, extra_artifacts) + ch_versions = ch_versions.mix(CLINICAL_GOVERNANCE.out.versions) + ch_sanitized_bams = CLINICAL_GOVERNANCE.out.sanitized_bams + ch_sanitized_vcfs = CLINICAL_GOVERNANCE.out.sanitized_vcfs } // --------------------------------------------------------------------- @@ -202,7 +264,7 @@ workflow LONGVARMETH { def ref_tuple_ch = fastq_ch.map { sample_id, fastq -> tuple(sample_id, [ref_nuc_path, ref_fai_path]) } - VISUALIZATION(ref_tuple_ch, phased_bams, final_vcfs, bed_methylation) + VISUALIZATION(ref_tuple_ch, ch_sanitized_bams, ch_sanitized_vcfs, bed_methylation) ch_versions = ch_versions.mix(VISUALIZATION.out.versions) ch_qc_files = ch_qc_files.mix(VISUALIZATION.out.methylation_html.map { meta, html -> html }) } diff --git a/workflows/nanopore.nf b/workflows/nanopore.nf index 30a653b..e32af78 100644 --- a/workflows/nanopore.nf +++ b/workflows/nanopore.nf @@ -16,6 +16,8 @@ include { VISUALIZATION } from '../subworkflows/local/visualization' include { REPORTING } from '../subworkflows/local/reporting' include { MERGE_BAMS } from '../modules/local/merge_bams' include { MOSDEPTH } from '../modules/nf-core/mosdepth' +include { SAMTOOLS_INDEX } from '../modules/nf-core/samtools/index' +include { BENCHMARKING } from '../subworkflows/local/benchmarking' workflow NANOPORE_PIPELINE { def samplesheet_file = file(params.input) @@ -43,28 +45,38 @@ workflow NANOPORE_PIPELINE { // Step 1: Input Checksum INPUT_CHECK(samples_ch) ch_versions = ch_versions.mix(INPUT_CHECK.out.versions) - - def raw_runs = samples_ch.filter { it[2] == 'pod5' || it[2] == 'fast5' } - def direct_bam_runs = samples_ch.filter { it[2] == 'bam' } + def ch_input_checksums = INPUT_CHECK.out.checksums + + // Branch samples by input type + def ch_branched = samples_ch.branch { + raw_signal: it[2] in ['pod5', 'fast5'] + unaligned: it[2] in ['bam', 'ubam', 'unaligned_bam'] + aligned: it[2] == 'aligned_bam' + fastq: it[2] in ['fastq', 'fastq.gz', 'fq', 'fq.gz'] + } // Step 3 (Raw ONT): Dorado GPU Basecalling & Demultiplexing - BASECALL_DEMUX(raw_runs) + BASECALL_DEMUX(ch_branched.raw_signal) def basecalled_bams = BASECALL_DEMUX.out.demux_bams ch_versions = ch_versions.mix(BASECALL_DEMUX.out.versions) - def direct_bams_raw = direct_bam_runs.map { run_id, input_path, input_type, kit, model, mod_model -> + def direct_bams_raw = ch_branched.unaligned.map { run_id, input_path, input_type, kit, model, mod_model -> tuple(run_id, file(input_path)) } MERGE_BAMS(direct_bams_raw) ch_versions = ch_versions.mix(MERGE_BAMS.out.versions) def direct_bams = MERGE_BAMS.out.bam - def all_demux_bams = basecalled_bams.concat(direct_bams) + def all_demux_bams = basecalled_bams.mix(direct_bams) // Step 2: Read QC READ_QC(all_demux_bams) ch_versions = ch_versions.mix(READ_QC.out.versions) - def fastq_ch = READ_QC.out.fastq + def fastq_ch = READ_QC.out.fastq.mix( + ch_branched.fastq.map { run_id, input_path, input_type, kit, model, mod_model -> + tuple(run_id, file(input_path)) + } + ) ch_qc_files = ch_qc_files .concat(READ_QC.out.fastqc_files) .concat(READ_QC.out.nanoplot_files) @@ -80,33 +92,54 @@ workflow NANOPORE_PIPELINE { } // Reference-guided Steps 4 - 11 - if (params.run_type == 'reference_based' && params.ref_nuc) { + if (params.run_type == 'reference_based') { def ref_nuc_path = file(params.ref_nuc) def ref_fai_path = file("${params.ref_nuc}.fai") - // Step 4: Minimap2 Alignment & Tag Restoration (skip if input is pre-aligned) - def aligned_bams = Channel.empty() - def pre_aligned_runs = samples_ch.filter { it[2] == 'aligned_bam' } - def need_alignment_runs = samples_ch.filter { it[2] != 'aligned_bam' } - - if (pre_aligned_runs) { - def pre_aligned_bams = pre_aligned_runs.map { run_id, input_path, input_type, kit, model, mod_model -> - def bam_file = file(input_path) - def bai_file = file("${input_path}.bai") - if (!bai_file.exists()) { - bai_file = file(input_path.replaceAll(/\.bam$/, '.bai')) - } - tuple(run_id, bam_file, bai_file) + // Pre-aligned BAMs: verify index existence or auto-generate with SAMTOOLS_INDEX + def ch_prealigned_check = ch_branched.aligned + .map { run_id, input_path, input_type, kit, model, mod_model -> + def bamFile = file(input_path) + def baiFile = file("${input_path}.bai") + def altBai = file(input_path.replaceAll(/\.bam$/, '.bai')) + def csiFile = file("${input_path}.csi") + def hasIdx = baiFile.exists() || altBai.exists() || csiFile.exists() + def resolvedBai = baiFile.exists() ? baiFile : (altBai.exists() ? altBai : (csiFile.exists() ? csiFile : null)) + tuple(run_id, bamFile, resolvedBai, hasIdx) + } + .branch { + has_index: it[3] == true + needs_index: it[3] == false + } + + def ch_prealigned_ready = ch_prealigned_check.has_index + .map { run_id, bam, bai, hasIdx -> + tuple(run_id, bam, bai, 'pre_aligned') } - aligned_bams = aligned_bams.mix(pre_aligned_bams) - } - if (need_alignment_runs && !params.skip_alignment) { + SAMTOOLS_INDEX(ch_prealigned_check.needs_index.map { run_id, bam, bai, hasIdx -> tuple(run_id, bam) }) + ch_versions = ch_versions.mix(SAMTOOLS_INDEX.out.versions) + def ch_prealigned_indexed = SAMTOOLS_INDEX.out.bam_bai + .map { run_id, bam, bai -> + tuple(run_id, bam, bai, 'pre_aligned_indexed') + } + + def ch_all_prealigned = ch_prealigned_ready.mix(ch_prealigned_indexed) + + // Step 4: Minimap2 Alignment & Tag Restoration (run for unaligned inputs) + def ch_newly_aligned = Channel.empty() + if (!params.skip_alignment) { ALIGNMENT(fastq_ch, all_demux_bams, ref_nuc_path) ch_versions = ch_versions.mix(ALIGNMENT.out.versions) - aligned_bams = aligned_bams.mix(ALIGNMENT.out.aligned_bam_bai) + ch_newly_aligned = ALIGNMENT.out.aligned_bam_bai + .map { run_id, bam, bai -> + tuple(run_id, bam, bai, 'pipeline_aligned') + } } + def ch_all_aligned_with_prov = ch_all_prealigned.mix(ch_newly_aligned) + def aligned_bams = ch_all_aligned_with_prov.map { run_id, bam, bai, prov -> tuple(run_id, bam, bai) } + // Post-alignment Coverage QC (Mosdepth) MOSDEPTH(aligned_bams, ref_nuc_path) ch_versions = ch_versions.mix(MOSDEPTH.out.versions) @@ -122,30 +155,36 @@ workflow NANOPORE_PIPELINE { ch_qc_files = ch_qc_files.concat(small_vcfs.map { it[1] }) } - // Step 6: Structural Variants & CNVs (Sniffles2 / CNVkit) + // Step 6: Structural Variants & CNVs (Sniffles2 / CNVkit / TRGT) def sv_vcfs = Channel.empty() + def sv_vcfs_tbi = Channel.empty() + def trgt_vcfs_tbi = Channel.empty() if (!params.skip_variants) { STRUCTURAL_VARIANTS(aligned_bams, ref_nuc_path) - ch_versions = ch_versions.mix(STRUCTURAL_VARIANTS.out.versions) - sv_vcfs = STRUCTURAL_VARIANTS.out.sv_vcf + ch_versions = ch_versions.mix(STRUCTURAL_VARIANTS.out.versions) + sv_vcfs = STRUCTURAL_VARIANTS.out.sv_vcf + sv_vcfs_tbi = STRUCTURAL_VARIANTS.out.sv_vcf_tbi + trgt_vcfs_tbi = STRUCTURAL_VARIANTS.out.trgt_vcf } - // Step 7: Methylation (Modkit pileup) + // Step 7: Epigenomics & Base Modifications (Modkit pileup for ONT; pb-cpg-tools for PacBio) def bed_methylation = Channel.empty() if (!params.skip_methylation) { METHYLATION(aligned_bams, ref_nuc_path) - ch_versions = ch_versions.mix(METHYLATION.out.versions) + ch_versions = ch_versions.mix(METHYLATION.out.versions) bed_methylation = METHYLATION.out.bed_methylation + ch_qc_files = ch_qc_files.mix(METHYLATION.out.qc_report.map { id, txt -> txt }) } - // Step 8: WhatsHap Phasing & Haplotagging + // Step 8: Multi-Variant Joint Co-Phasing & Haplotagging def phased_bams = aligned_bams def phased_vcfs = small_vcfs if (!params.skip_variants && (params.species == 'human' || params.enable_phasing)) { - PHASING(aligned_bams, small_vcfs, sv_vcfs, ref_nuc_path, ref_fai_path) - ch_versions = ch_versions.mix(PHASING.out.versions) - phased_bams = PHASING.out.phased_bam_bai - phased_vcfs = PHASING.out.phased_vcf + PHASING(aligned_bams, small_vcfs, sv_vcfs_tbi, trgt_vcfs_tbi, ref_nuc_path, ref_fai_path) + ch_versions = ch_versions.mix(PHASING.out.versions) + phased_bams = PHASING.out.phased_bam_bai + phased_vcfs = PHASING.out.phased_vcf + ch_qc_files = ch_qc_files.mix(PHASING.out.phasing_stats.map { id, stats -> stats }) } // Step 9: Annotation @@ -156,21 +195,43 @@ workflow NANOPORE_PIPELINE { final_vcfs = ANNOTATION.out.annotated_vcf } - // Step 10: Clinical Governance + // Step 10: Truth-Set Benchmarking (GIAB HG002) + if (params.run_benchmarking) { + def truth_snv_vcf = params.benchmark_truth_vcf ? file(params.benchmark_truth_vcf) : null + def truth_snv_bed = params.benchmark_truth_bed ? file(params.benchmark_truth_bed) : null + def truth_sv_vcf = params.benchmark_sv_truth_vcf ? file(params.benchmark_sv_truth_vcf) : null + def truth_sv_bed = params.benchmark_sv_truth_bed ? file(params.benchmark_sv_truth_bed) : null + + BENCHMARKING( + phased_vcfs, + sv_vcfs_tbi, + ref_nuc_path, + ref_fai_path, + truth_snv_vcf, + truth_snv_bed, + truth_sv_vcf, + truth_sv_bed + ) + ch_versions = ch_versions.mix(BENCHMARKING.out.versions) + ch_qc_files = ch_qc_files.mix(BENCHMARKING.out.qc_files) + } + + // Step 11: Clinical Governance & Forensic Audit Trail + def ch_sanitized_bams = phased_bams + def ch_sanitized_vcfs = final_vcfs if (params.enable_audit_trail || params.enable_phi_sanitization) { - def audit_targets = aligned_bams - .map { sample_id, bam, bai -> - tuple(sample_id, [bam, bai]) - } - CLINICAL_GOVERNANCE(aligned_bams, audit_targets) - ch_versions = ch_versions.mix(CLINICAL_GOVERNANCE.out.versions) + def extra_artifacts = bed_methylation.map { id, bed -> tuple(id, [bed]) } + CLINICAL_GOVERNANCE(phased_bams, final_vcfs, ch_input_checksums, ref_nuc_path, extra_artifacts) + ch_versions = ch_versions.mix(CLINICAL_GOVERNANCE.out.versions) + ch_sanitized_bams = CLINICAL_GOVERNANCE.out.sanitized_bams + ch_sanitized_vcfs = CLINICAL_GOVERNANCE.out.sanitized_vcfs } // Step 11: Visualization (JBrowse 2, Methylartist & Methylation Dashboard) def ref_tuple_ch = fastq_ch.map { sample_id, fastq -> tuple(sample_id, [ref_nuc_path, ref_fai_path]) } - VISUALIZATION(ref_tuple_ch, phased_bams, final_vcfs, bed_methylation) + VISUALIZATION(ref_tuple_ch, ch_sanitized_bams, ch_sanitized_vcfs, bed_methylation) ch_versions = ch_versions.mix(VISUALIZATION.out.versions) ch_qc_files = ch_qc_files.mix(VISUALIZATION.out.methylation_html.map { meta, html -> html }) } diff --git a/workflows/pacbio.nf b/workflows/pacbio.nf index 64345cc..fee5c20 100644 --- a/workflows/pacbio.nf +++ b/workflows/pacbio.nf @@ -15,6 +15,8 @@ include { VISUALIZATION } from '../subworkflows/local/visualization' include { REPORTING } from '../subworkflows/local/reporting' include { MERGE_BAMS } from '../modules/local/merge_bams' include { MOSDEPTH } from '../modules/nf-core/mosdepth' +include { SAMTOOLS_INDEX } from '../modules/nf-core/samtools/index' +include { BENCHMARKING } from '../subworkflows/local/benchmarking' workflow PACBIO_PIPELINE { def samplesheet_file = file(params.input) @@ -42,9 +44,17 @@ workflow PACBIO_PIPELINE { // Step 1: Input Checksum INPUT_CHECK(samples_ch) ch_versions = ch_versions.mix(INPUT_CHECK.out.versions) + def ch_input_checksums = INPUT_CHECK.out.checksums - // Ingest PacBio BAMs - def pacbio_bams_raw = samples_ch.map { run_id, input_path, input_type, kit, model, mod_model -> + // Branch samples by input type + def ch_branched = samples_ch.branch { + unaligned: it[2] in ['pacbio_bam', 'bam', 'ubam', 'hifi', 'pacbio_hifi', 'unaligned_bam'] + aligned: it[2] == 'aligned_bam' + fastq: it[2] in ['fastq', 'fastq.gz', 'fq', 'fq.gz'] + } + + // Ingest PacBio unaligned BAMs + def pacbio_bams_raw = ch_branched.unaligned.map { run_id, input_path, input_type, kit, model, mod_model -> tuple(run_id, file(input_path)) } MERGE_BAMS(pacbio_bams_raw) @@ -54,7 +64,11 @@ workflow PACBIO_PIPELINE { // Step 2: Read QC READ_QC(pacbio_bams) ch_versions = ch_versions.mix(READ_QC.out.versions) - def fastq_ch = READ_QC.out.fastq + def fastq_ch = READ_QC.out.fastq.mix( + ch_branched.fastq.map { run_id, input_path, input_type, kit, model, mod_model -> + tuple(run_id, file(input_path)) + } + ) ch_qc_files = ch_qc_files .concat(READ_QC.out.fastqc_files) .concat(READ_QC.out.nanoplot_files) @@ -70,33 +84,54 @@ workflow PACBIO_PIPELINE { } // Reference-guided Steps 4 - 11 - if (params.run_type == 'reference_based' && params.ref_nuc) { + if (params.run_type == 'reference_based') { def ref_nuc_path = file(params.ref_nuc) def ref_fai_path = file("${params.ref_nuc}.fai") - // Step 4: pbmm2 HiFi Alignment (skip if input is pre-aligned) - def aligned_bams = Channel.empty() - def pre_aligned_runs = samples_ch.filter { it[2] == 'aligned_bam' } - def need_alignment_runs = samples_ch.filter { it[2] != 'aligned_bam' } - - if (pre_aligned_runs) { - def pre_aligned_bams = pre_aligned_runs.map { run_id, input_path, input_type, kit, model, mod_model -> - def bam_file = file(input_path) - def bai_file = file("${input_path}.bai") - if (!bai_file.exists()) { - bai_file = file(input_path.replaceAll(/\.bam$/, '.bai')) - } - tuple(run_id, bam_file, bai_file) + // Pre-aligned BAMs: verify index existence or auto-generate with SAMTOOLS_INDEX + def ch_prealigned_check = ch_branched.aligned + .map { run_id, input_path, input_type, kit, model, mod_model -> + def bamFile = file(input_path) + def baiFile = file("${input_path}.bai") + def altBai = file(input_path.replaceAll(/\.bam$/, '.bai')) + def csiFile = file("${input_path}.csi") + def hasIdx = baiFile.exists() || altBai.exists() || csiFile.exists() + def resolvedBai = baiFile.exists() ? baiFile : (altBai.exists() ? altBai : (csiFile.exists() ? csiFile : null)) + tuple(run_id, bamFile, resolvedBai, hasIdx) + } + .branch { + has_index: it[3] == true + needs_index: it[3] == false + } + + def ch_prealigned_ready = ch_prealigned_check.has_index + .map { run_id, bam, bai, hasIdx -> + tuple(run_id, bam, bai, 'pre_aligned') } - aligned_bams = aligned_bams.mix(pre_aligned_bams) - } - if (need_alignment_runs && !params.skip_alignment) { + SAMTOOLS_INDEX(ch_prealigned_check.needs_index.map { run_id, bam, bai, hasIdx -> tuple(run_id, bam) }) + ch_versions = ch_versions.mix(SAMTOOLS_INDEX.out.versions) + def ch_prealigned_indexed = SAMTOOLS_INDEX.out.bam_bai + .map { run_id, bam, bai -> + tuple(run_id, bam, bai, 'pre_aligned_indexed') + } + + def ch_all_prealigned = ch_prealigned_ready.mix(ch_prealigned_indexed) + + // Step 4: pbmm2 HiFi Alignment (run for unaligned inputs) + def ch_newly_aligned = Channel.empty() + if (!params.skip_alignment) { ALIGNMENT(fastq_ch, pacbio_bams, ref_nuc_path) ch_versions = ch_versions.mix(ALIGNMENT.out.versions) - aligned_bams = aligned_bams.mix(ALIGNMENT.out.aligned_bam_bai) + ch_newly_aligned = ALIGNMENT.out.aligned_bam_bai + .map { run_id, bam, bai -> + tuple(run_id, bam, bai, 'pipeline_aligned') + } } + def ch_all_aligned_with_prov = ch_all_prealigned.mix(ch_newly_aligned) + def aligned_bams = ch_all_aligned_with_prov.map { run_id, bam, bai, prov -> tuple(run_id, bam, bai) } + // Post-alignment Coverage QC (Mosdepth) MOSDEPTH(aligned_bams, ref_nuc_path) ch_versions = ch_versions.mix(MOSDEPTH.out.versions) @@ -114,28 +149,34 @@ workflow PACBIO_PIPELINE { // Step 6: SVs, HiFiCNV, TRGT def sv_vcfs = Channel.empty() + def sv_vcfs_tbi = Channel.empty() + def trgt_vcfs_tbi = Channel.empty() if (!params.skip_variants) { STRUCTURAL_VARIANTS(aligned_bams, ref_nuc_path) - ch_versions = ch_versions.mix(STRUCTURAL_VARIANTS.out.versions) - sv_vcfs = STRUCTURAL_VARIANTS.out.sv_vcf + ch_versions = ch_versions.mix(STRUCTURAL_VARIANTS.out.versions) + sv_vcfs = STRUCTURAL_VARIANTS.out.sv_vcf + sv_vcfs_tbi = STRUCTURAL_VARIANTS.out.sv_vcf_tbi + trgt_vcfs_tbi = STRUCTURAL_VARIANTS.out.trgt_vcf } - // Step 7: Epigenomics (pbcpgtools & modkit) + // Step 7: Epigenomics (pb-cpg-tools native HiFi methylation) def bed_methylation = Channel.empty() if (!params.skip_methylation) { METHYLATION(aligned_bams, ref_nuc_path) - ch_versions = ch_versions.mix(METHYLATION.out.versions) + ch_versions = ch_versions.mix(METHYLATION.out.versions) bed_methylation = METHYLATION.out.bed_methylation + ch_qc_files = ch_qc_files.mix(METHYLATION.out.qc_report.map { id, txt -> txt }) } - // Step 8: HiPhase Co-Phasing + // Step 8: HiPhase Joint Multi-Variant Co-Phasing (SNVs + SVs + STRs) def phased_bams = aligned_bams def phased_vcfs = small_vcfs if (!params.skip_variants && (params.species == 'human' || params.enable_phasing)) { - PHASING(aligned_bams, small_vcfs, sv_vcfs, ref_nuc_path, ref_fai_path) - ch_versions = ch_versions.mix(PHASING.out.versions) - phased_bams = PHASING.out.phased_bam_bai - phased_vcfs = PHASING.out.phased_vcf + PHASING(aligned_bams, small_vcfs, sv_vcfs_tbi, trgt_vcfs_tbi, ref_nuc_path, ref_fai_path) + ch_versions = ch_versions.mix(PHASING.out.versions) + phased_bams = PHASING.out.phased_bam_bai + phased_vcfs = PHASING.out.phased_vcf + ch_qc_files = ch_qc_files.mix(PHASING.out.phasing_stats.map { id, stats -> stats }) } // Step 9: Annotation @@ -146,21 +187,43 @@ workflow PACBIO_PIPELINE { final_vcfs = ANNOTATION.out.annotated_vcf } - // Step 10: Clinical Governance + // Step 10: Truth-Set Benchmarking (GIAB HG002) + if (params.run_benchmarking) { + def truth_snv_vcf = params.benchmark_truth_vcf ? file(params.benchmark_truth_vcf) : null + def truth_snv_bed = params.benchmark_truth_bed ? file(params.benchmark_truth_bed) : null + def truth_sv_vcf = params.benchmark_sv_truth_vcf ? file(params.benchmark_sv_truth_vcf) : null + def truth_sv_bed = params.benchmark_sv_truth_bed ? file(params.benchmark_sv_truth_bed) : null + + BENCHMARKING( + phased_vcfs, + sv_vcfs_tbi, + ref_nuc_path, + ref_fai_path, + truth_snv_vcf, + truth_snv_bed, + truth_sv_vcf, + truth_sv_bed + ) + ch_versions = ch_versions.mix(BENCHMARKING.out.versions) + ch_qc_files = ch_qc_files.mix(BENCHMARKING.out.qc_files) + } + + // Step 11: Clinical Governance & Forensic Audit Trail + def ch_sanitized_bams = phased_bams + def ch_sanitized_vcfs = final_vcfs if (params.enable_audit_trail || params.enable_phi_sanitization) { - def audit_targets = aligned_bams - .map { sample_id, bam, bai -> - tuple(sample_id, [bam, bai]) - } - CLINICAL_GOVERNANCE(aligned_bams, audit_targets) - ch_versions = ch_versions.mix(CLINICAL_GOVERNANCE.out.versions) + def extra_artifacts = bed_methylation.map { id, bed -> tuple(id, [bed]) } + CLINICAL_GOVERNANCE(phased_bams, final_vcfs, ch_input_checksums, ref_nuc_path, extra_artifacts) + ch_versions = ch_versions.mix(CLINICAL_GOVERNANCE.out.versions) + ch_sanitized_bams = CLINICAL_GOVERNANCE.out.sanitized_bams + ch_sanitized_vcfs = CLINICAL_GOVERNANCE.out.sanitized_vcfs } // Step 11: Visualization (JBrowse 2, Methylartist & Methylation Dashboard) def ref_tuple_ch = fastq_ch.map { sample_id, fastq -> tuple(sample_id, [ref_nuc_path, ref_fai_path]) } - VISUALIZATION(ref_tuple_ch, phased_bams, final_vcfs, bed_methylation) + VISUALIZATION(ref_tuple_ch, ch_sanitized_bams, ch_sanitized_vcfs, bed_methylation) ch_versions = ch_versions.mix(VISUALIZATION.out.versions) ch_qc_files = ch_qc_files.mix(VISUALIZATION.out.methylation_html.map { meta, html -> html }) } From d8da6228161182fb23c32754bb0fa6903ad71fe7 Mon Sep 17 00:00:00 2001 From: Jyotirmoy Das Date: Fri, 30 Oct 2026 14:50:17 +0100 Subject: [PATCH 13/13] chore(release): v0.4.0 - pipeline hardening, benchmarking, clinical governance, and security audit --- CHANGELOG.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ nextflow.config | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a22c66..bda7ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.4.0] - 2026-10-30 + +### Added +- **Modular SNV/Indel Caller Dispatch Matrix**: + - Added **`modules/nf-core/deepvariant`** supporting platform-specific neural models (`PACBIO` and `ONT_R104`). + - Added **`modules/nf-core/clair3`** supporting long-read neural models for ONT and PacBio HiFi. + - Upgraded `subworkflows/local/variant_calling/main.nf` with dynamic caller selection via `--snv_caller` (`bcftools`, `deepvariant`, `clair3`). +- **Epigenomics Specialization**: + - Added **`modules/nf-core/pbcpgtools`** for native PacBio HiFi CpG aggregation (`aligned_bam_to_cpg_scores`). + - Implemented platform-split epigenomics in `subworkflows/local/methylation/main.nf`: native `pbcpgtools` for PacBio HiFi vs `modkit pileup` for Oxford Nanopore. + - Added **`CHECK_MOD_TAGS`** validation process ensuring SAM auxiliary kinetic tags (`MM`/`ML`) are verified prior to pileup. +- **Joint Multi-Variant Co-Phasing**: + - Upgraded **`modules/nf-core/hiphase`** to jointly co-phase small variants (SNVs/indels), structural variants (`Sniffles2`), and tandem repeat expansions (`TRGT`) into unified phase sets (`PS`). + - Added **`modules/nf-core/whatshap/stats`** for phase block metric calculation and N50 reporting. +- **Empirical GIAB HG002 Truth-Set Benchmarking**: + - Added **`modules/nf-core/happy`** for small variant (SNV/Indel) concordance evaluation against NIST GIAB truth sets. + - Added **`modules/nf-core/truvari`** for structural variant ($\ge 50$ bp) precision and recall benchmarking against GIAB Tier 1 benchmarks. + - Created **`subworkflows/local/benchmarking/main.nf`** integrated into all pipeline entrypoints. + - Added dedicated benchmark profile **`-profile test_giab`** (`conf/tests/test_giab.config`) and mock truth assets in `tests/data/giab/`. + - Authored comprehensive validation guide and baseline expectations in **`docs/validation.md`**. +- **Transparent MultiQC Analytical Stage Execution Matrix**: + - Implemented **`modules/local/execution_status_matrix/main.nf`** generating `pipeline_execution_status_mqc.tsv`. + - Wired into MultiQC (`assets/multiqc_config.yml`) to transparently report the status (`COMPLETED`, `SKIPPED (Pre-aligned Input)`, `SKIPPED (Parameter-Disabled)`, `FAILED`) across all 12 pipeline analytical stages. +- **Automated Security, Secret & PHI Auditing**: + - Added **`bin/security_audit.py`** and root-level runner **`security_check.sh`** for zero-dependency scanning of credentials, tokens, cloud keys, private keys, local user paths, and PHI. + - Added **`.pre-commit-config.yaml`** for local git pre-commit hook integration. + - Integrated fail-fast security gate into **`.github/workflows/ci.yml`** to block pull requests and commits containing sensitive data. +- **Automated Negative Test Regression Suite**: + - Added **`tests/run_negative_tests.sh`** and **`tests/pipeline/negative_tests.nf.test`** testing fail-fast exit codes (exit 1) on missing references, platform mismatches, and invalid samplesheets. +- **On-Demand Auto-Indexing**: + - Added **`modules/nf-core/samtools/index`** and **`modules/nf-core/samtools/faidx`** for automatic indexing of pre-aligned BAMs and reference FASTA files. + +### Changed +- **Regulatory Scope & Intended-Use Reframing**: + - Re-scoped `README.md` and `docs/validation.md` strictly to **Research Use Only (RUO) / Clinical Evaluation Platform**, clarifying that clinical diagnostic deployment requires site-specific analytical validation (e.g. CAP/CLIA or ISO 15189). + - Documented physical chemistry boundaries (Oxford Nanopore simplex homopolymer indel resolution, 5mC vs 5hmC depth requirements, PacBio HiFi coverage and kinetic tags, phase block continuity limits). +- **Native Streaming Tag Preservation**: + - Deprecated in-memory dictionary-based `transfer_mod_tags.py`, replacing it with native streaming alignment (`minimap2 -ax map-ont -y` and `pbmm2`), guaranteeing coordinate-accurate reverse-strand `MM`/`ML` tag propagation with minimal memory overhead. +- **Full-Chain PHI De-Identification & Forensic Audit Manifest**: + - Upgraded `subworkflows/local/governance`: Dedicated `SANITIZE_BAM` and `SANITIZE_VCF` generating deterministic `anon_id`, stripping patient identifiers, hospital run barcodes, and tool command lines. + - Re-routed downstream `VISUALIZATION` (JBrowse 2, Methylartist, Dashboard) and `REPORTING` across all workflows to consume exclusively sanitized artifacts. + - Expanded `manifest.json` into a cryptographic full-chain provenance record binding raw input SHAs, reference FASTA checksums, workflow commit ID, container digests, execution parameters, and output checksums. + - Pinned container image digests in `conf/version_lock.config`. + +### Fixed +- **Groovy Boolean Channel Evaluation Bug**: + - Replaced Groovy boolean channel checks (`if (channel)`) with idiomatic Nextflow DSL2 `.branch { ... }` operators in `nanopore.nf`, `pacbio.nf`, and `longvarmeth.nf`. + - Fixed dropped FASTQ inputs and prevented pre-aligned BAMs from incorrectly entering `MERGE_BAMS`. +- **Silent Parameter Bypass on Missing Reference**: + - Updated `lib/Utils.groovy` with fatal fail-fast validation: immediately terminates execution (`exit 1`) with an explicit error banner when `--run_type reference_based` lacks a valid `--ref_nuc`. +- **Platform & Chemistry Cross-Validation**: + - Added strict platform validation in `lib/Utils.groovy`, instantly rejecting mismatched platform inputs (e.g. PacBio BAMs passed to `--platform ont`). + - Added duplicate sample ID detection and required column header validation. +- **Channel Closure Arity**: + - Resolved variable tuple arity exceptions in multi-channel joins within `phasing/main.nf` and `governance/main.nf`. + +### Security +- Sanitized remaining host-specific filesystem paths (`/data/Jyotirmoy/...`) from `conf/fraka.config`, `conf/human.config`, `conf/eukaryote.config`, and template `samplesheet.csv`. +- Hardened `.gitignore` to strictly ignore private keys (`*.key`, `*.pem`, `*.p12`, `*.pfx`, `id_rsa*`, `id_ed25519*`), environment files (`.env*`), and sensitive credentials. +- Added automated fail-fast security scanning in CI (`.github/workflows/ci.yml`). + +--- + ## [0.3.0] - 2026-10-12 ### Added diff --git a/nextflow.config b/nextflow.config index 9923443..a127a65 100644 --- a/nextflow.config +++ b/nextflow.config @@ -5,7 +5,7 @@ manifest { author = 'Jyotirmoy Das' description = 'LongVarMeth: Unified Long-Read (Nanopore & PacBio) Variant Calling and Methylation Analysis Pipeline' mainScript = 'main.nf' - version = '0.3.0' + version = '0.4.0' } // Load params.config FIRST so parameters are available to other configs