diff --git a/modules/nf-core/custom/orfnormalise/meta.yml b/modules/nf-core/custom/orfnormalise/meta.yml index f7c4e6a06b14..ac1ffd1b891f 100644 --- a/modules/nf-core/custom/orfnormalise/meta.yml +++ b/modules/nf-core/custom/orfnormalise/meta.yml @@ -35,8 +35,11 @@ description: | `orf_type_native` carries the caller's own ORF-type label verbatim, so every harmonisation decision below stays auditable without re-running the caller. ORF-type tokens are matched exactly (casefolded) against each caller's closed - vocabulary. Tokens matching no entry cause the process to fail; successful - outputs report `unmapped_orf_type=0` on the `# parser_columns:` provenance line. + vocabulary. Where a caller qualifies a positional label after a colon, as + Ribo-TISH does with `Novel:CDSFrameOverlap`, only the part before the colon is + matched and the qualifier survives in `orf_type_native`. Tokens whose location + matches no entry cause the process to fail; successful outputs report + `unmapped_orf_type=0` on the `# parser_columns:` provenance line. `orf_class` is purely positional: it records where the ORF sits relative to the annotated CDS and never encodes its length. Select small ORFs with the diff --git a/modules/nf-core/custom/orfnormalise/templates/orfnormalise.py b/modules/nf-core/custom/orfnormalise/templates/orfnormalise.py index 172ddae78f20..41594c19418d 100644 --- a/modules/nf-core/custom/orfnormalise/templates/orfnormalise.py +++ b/modules/nf-core/custom/orfnormalise/templates/orfnormalise.py @@ -413,6 +413,9 @@ def write_outputs(bed_path, tsv_path, bed_lines, tsv_rows, parser_columns, unmap # Every caller's ORF-type vocabulary is a closed enum, so tokens are matched # exactly (casefolded) rather than by substring. Substring matching mis-fired # on the overlap forms, because "uorf" is a substring of "overlap_uorf". +# +# Keys hold the positional part only. Ribo-TISH qualifies it after a colon +# (`Novel:CDSFrameOverlap`); classify() splits there, so no key may contain one. # ---------------------------------------------------------------------------- CLASS_TOKENS = { @@ -498,13 +501,21 @@ def write_outputs(bed_path, tsv_path, bed_lines, tsv_rows, parser_columns, unmap def classify(caller, orf_type): """Map a caller's native ORF-type token to the harmonised class. - Returns (orf_class, matched); `matched` is False when a non-empty token - matched no entry, so unmapped labels can be counted rather than silently - absorbed into `other`. + Callers may qualify a positional label rather than replace it: Ribo-TISH + emits `Novel:CDSFrameOverlap`, `3'UTR:CDSFrameOverlap` and similar. Only the + part before the first colon describes where the ORF sits, and `orf_class` + is positional, so the qualifier is dropped here and preserved verbatim in + `orf_type_native`. No CLASS_TOKENS key contains a colon, so this cannot + shorten a token that was meant to match whole. + + Returns (orf_class, matched); `matched` is False when a non-empty token's + location matched no entry, so unmapped labels can be counted rather than + silently absorbed into `other`. """ if not orf_type: return "other", True - cls = CLASS_TOKENS[caller].get(orf_type.strip().casefold()) + location = orf_type.split(":", 1)[0] + cls = CLASS_TOKENS[caller].get(location.strip().casefold()) if cls is None: return "other", False return cls, True diff --git a/modules/nf-core/custom/orfnormalise/tests/main.nf.test b/modules/nf-core/custom/orfnormalise/tests/main.nf.test index 07af9516426a..6164f790117d 100644 --- a/modules/nf-core/custom/orfnormalise/tests/main.nf.test +++ b/modules/nf-core/custom/orfnormalise/tests/main.nf.test @@ -431,4 +431,51 @@ nextflow_process { } } + test("ribotish composite ORF types map on the location, keeping the qualifier native") { + + when { + process { + """ + input[0] = channel + .of( + 'Tid\tGid\tGenomePos\tTisType\tAALen\tFisherPvalue', + 't1\tg1\tchr1:1000-1100:+\tNovel:CDSFrameOverlap\t30\t0.001', + "t1\tg1\tchr1:1100-1200:+\t3'UTR:CDSFrameOverlap\t30\t0.001", + 't1\tg1\tchr1:1200-1300:+\tInternal:CDSFrameOverlap\t30\t0.001', + 't1\tg1\tchr1:1300-1400:+\tNovel:Known\t30\t0.001' + ) + .collectFile(name: 'composite.ribotish.pred.txt', newLine: true, sort: false) + .map { orfs -> [[id: 'sample1'], orfs, 'ribotish'] } + input[1] = channel + .of('chr1\ttest\texon\t1001\t2000\t.\t+\t.\tgene_id "g1"; transcript_id "t1";') + .collectFile(name: 'composite.gtf', newLine: true) + .map { gtf -> [[id: 'reference'], gtf] } + """ + } + } + + then { + def lines = path(process.out.tsv[0][1]).text.readLines() + def header = lines.find { it.startsWith('orf_id') }.split('\t') as List + def data = lines.findAll { !it.startsWith('#') && !it.startsWith('orf_id') } + def i_class = header.indexOf('orf_class') + def i_native = header.indexOf('orf_type_native') + def classes = data.collect { it.split('\t')[i_class] } as Set + def natives = data.collect { it.split('\t')[i_native] } + def qualified = natives.findAll { it.contains(':') } + def provenance = lines.find { it.startsWith('# parser_columns:') } + + assertAll( + // The colon-qualified tokens no longer abort the process: only the part + // before the colon is positional, so it alone selects the class. + { assert process.success }, + { assert classes == ['novel_u', 'dORF', 'intORF'] as Set }, + // The qualifier is dropped from orf_class but not lost. + { assert qualified.size() == natives.size() }, + { assert natives.contains('Novel:CDSFrameOverlap') }, + { assert provenance.contains('unmapped_orf_type=0') } + ) + } + } + }