diff --git a/assets/catalog/catalog.md b/assets/catalog/catalog.md index 19ab2b9..452203e 100644 --- a/assets/catalog/catalog.md +++ b/assets/catalog/catalog.md @@ -122,6 +122,8 @@ - [workflows/tiberius](../../workflows/tiberius/main.nf) — Standalone Tiberius pipeline. - [oriongeno/predict](../../modules/nextflow/oriongeno/predict/main.nf) — OrionGeno ab initio gene annotation on one GPU (`process_gpu`). No CPU fallback. Default checkpoint: mammals. GTF by default, GFF3 via `meta.oriongeno_format`. Bundled OrionGeno is non-commercial. - [oriongeno/multi](../../modules/nextflow/oriongeno/multi/main.nf) — OrionGeno annotation across the GPUs assigned to the task (`oriongeno multi`). Writes GTF. `process_gpu` requests one GPU; raise `accelerator` on `ORIONGENO_MULTI` to use more. +- [subworkflows/oriongeno](../../subworkflows/oriongeno/main.nf) — Scatter (none / chromosome / weighted), predict on one GPU per piece, and gather. Renumbers gene ids in FASTA order. +- [workflows/oriongeno](../../workflows/oriongeno/main.nf) — Standalone OrionGeno pipeline. Bundled OrionGeno is non-commercial. --- diff --git a/subworkflows/oriongeno/main.nf b/subworkflows/oriongeno/main.nf new file mode 100644 index 0000000..a326bd6 --- /dev/null +++ b/subworkflows/oriongeno/main.nf @@ -0,0 +1,507 @@ +/* +Copyright (c) 2026 The Hiller Lab at the Senckenberg Gessellschaft für Naturforschung +Distributed under the terms of the Apache License, Version 2.0. +*/ + +/* +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ORIONGENO — Scatter a genome, annotate each piece on one GPU, gather. + + Uses ORIONGENO_PREDICT (one GPU per piece), not ORIONGENO_MULTI. + Scatter is none, chromosome, or weighted. Each predict job restarts + gene ids at g1 and repeat ids at r1. Gather puts chunks back in FASTA + order and renumbers those ids. Repeats are gathered only when requested. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +*/ + +include { FXSPLIT } from '../../modules/nextflow/fxsplit/main.nf' +include { ORIONGENO_PREDICT } from '../../modules/nextflow/oriongeno/predict/main.nf' + +def oriongenoLineages() { + return ['mammals', 'birds', 'fish', 'other_vertebrates', 'arthropods', 'other_invertebrates', 'plants', 'fungi'] +} + +def oriongenoScatterModes() { + return ['none', 'chromosome', 'weighted'] +} + +def oriongenoBool(value) { + if (value == null) { + return false + } + if (value instanceof Boolean) { + return value + } + def text = value.toString().trim().toLowerCase() + if (text in ['true', '1', 'yes', 'y', 'on']) { + return true + } + if (text in ['false', '0', 'no', 'n', 'off', '']) { + return false + } + error "Cannot parse boolean value: ${value}" +} + +def oriongenoPackBins(records, nBins) { + def n = Math.max(1, nBins as int) + n = Math.min(n, records.size()) + def bins = (0.. + def byLen = (b.length as long) <=> (a.length as long) + byLen != 0 ? byLen : ((a.order as int) <=> (b.order as int)) + }.each { rec -> + def target = bins.min { x, y -> x.total <=> y.total ?: x.order <=> y.order } + target.records << rec + target.total += rec.length as long + } + bins.each { bin -> bin.records = bin.records.toSorted { it.order } } + return bins.findAll { !it.records.isEmpty() } +} + +def oriongenoResolveRecord(seqid, files) { + def exact = files.find { it.baseName == seqid } + if (exact) { + return exact + } + def prefixed = files.find { it.baseName.startsWith(seqid + '.') || it.baseName.startsWith(seqid + '_') } + if (prefixed) { + return prefixed + } + def safe = seqid.replaceAll(/[^A-Za-z0-9._-]/, '_') + def sanitized = files.find { it.baseName == safe || it.baseName.startsWith(safe + '.') } + if (sanitized) { + return sanitized + } + error "No FXSPLIT output for FASTA record '${seqid}' among ${files*.name}" +} + +def oriongenoParseManifest(path) { + def records = [] + path.withReader { reader -> + def header = reader.readLine() + if (header == null || header.split('\t')*.trim() != ['order', 'seqid', 'length']) { + error "Malformed OrionGeno manifest header in ${path}: '${header}' (expected 'order\\tseqid\\tlength')" + } + reader.eachLine { line -> + if (!line) { + return + } + def parts = line.split('\t', 3) + if (parts.size() < 3 || !parts[0].trim().matches(/\d+/) || !parts[2].trim().matches(/\d+/)) { + error "Malformed OrionGeno manifest line in ${path}: '${line}' (expected 'order\\tseqid\\tlength' with numeric order/length)" + } + records << [ + order : parts[0] as int, + seqid : parts[1], + length: parts[2] as long, + ] + } + } + return records +} + +process ORIONGENO_MANIFEST { + tag "$meta.id" + label 'process_single' + + container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ? + '' : + 'ghcr.io/hillerlab/oriongeno:latest' }" + + input: + tuple val(meta), path(fasta) + path scatter_py + + output: + tuple val(meta), path("records.tsv"), emit: manifest + path "versions.yml", emit: versions + + script: + """ + python3 ${scatter_py} manifest ${fasta} records.tsv + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: \$( python3 --version 2>/dev/null | sed 's/Python //' || echo 3 ) + END_VERSIONS + """ + + stub: + """ + printf 'order\\tseqid\\tlength\\n0\\tchr1\\t1\\n' > records.tsv + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: 3 + END_VERSIONS + """ +} + +process ORIONGENO_CAT { + tag "$meta.id" + label 'process_single' + + container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ? + '' : + 'ghcr.io/hillerlab/oriongeno:latest' }" + + input: + tuple val(meta), path(fastas) + + output: + tuple val(meta), path("combined.fa"), emit: fasta + path "versions.yml", emit: versions + + script: + """ + cat ${fastas} > combined.fa + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + cat: \$( cat --version | head -n 1 | sed 's/.* //' ) + END_VERSIONS + """ + + stub: + """ + touch combined.fa + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + cat: na + END_VERSIONS + """ +} + +process ORIONGENO_GATHER { + tag "$meta.id" + label 'process_single' + + container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ? + '' : + 'ghcr.io/hillerlab/oriongeno:latest' }" + + input: + tuple val(meta), path(annotations), path(manifest) + path scatter_py + + output: + tuple val(meta), path("${prefix}.${ext}"), emit: annotation + path "versions.yml", emit: versions + + script: + prefix = task.ext.prefix ?: "${meta.id}" + ext = (meta.oriongeno_ext ?: 'gtf').toString() + """ + python3 ${scatter_py} genes ${ext} ${manifest} ${prefix}.${ext} ${annotations} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: \$( python3 --version 2>/dev/null | sed 's/Python //' || echo 3 ) + END_VERSIONS + """ + + stub: + prefix = task.ext.prefix ?: "${meta.id}" + ext = (meta.oriongeno_ext ?: 'gtf').toString() + """ + touch ${prefix}.${ext} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: 3 + END_VERSIONS + """ +} + +process ORIONGENO_GATHER_REPEATS { + tag "$meta.id" + label 'process_single' + + container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ? + '' : + 'ghcr.io/hillerlab/oriongeno:latest' }" + + input: + tuple val(meta), path(annotations), path(manifest) + path scatter_py + + output: + tuple val(meta), path("${prefix}.repeat.${ext}"), emit: annotation + path "versions.yml", emit: versions + + script: + prefix = task.ext.prefix ?: "${meta.id}" + ext = (meta.oriongeno_ext ?: 'gtf').toString() + """ + python3 ${scatter_py} repeats ${ext} ${manifest} ${prefix}.repeat.${ext} ${annotations} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: \$( python3 --version 2>/dev/null | sed 's/Python //' || echo 3 ) + END_VERSIONS + """ + + stub: + prefix = task.ext.prefix ?: "${meta.id}" + ext = (meta.oriongeno_ext ?: 'gtf').toString() + """ + touch ${prefix}.repeat.${ext} + + cat <<-END_VERSIONS > versions.yml + "${task.process}": + python: 3 + END_VERSIONS + """ +} + +/* +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ORIONGENO subworkflow +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +*/ + +workflow ORIONGENO { + take: + genome // channel: [ val(meta), path(fasta) ] + lineage // value: mammals|birds|fish|other_vertebrates|arthropods|other_invertebrates|plants|fungi + scatter_mode // value: none|chromosome|weighted + scatter_bins // value: integer, used when scatter_mode == weighted + format // value: gtf|gff|gff3 + output_repeat // value: boolean + + main: + ch_versions = Channel.empty() + def scatter_py = file("${moduleDir}/oriongeno_scatter.py", checkIfExists: true) + + def lin = lineage.toString() + def lineages = oriongenoLineages() + if (!lineages.contains(lin)) { + error "Unsupported OrionGeno lineage '${lin}'. Choose one of: ${lineages.join(', ')}" + } + + def mode = scatter_mode.toString() + def scatter_modes = oriongenoScatterModes() + if (!scatter_modes.contains(mode)) { + error "Unsupported OrionGeno scatter mode '${mode}'. Choose one of: ${scatter_modes.join(', ')}" + } + + def normalized = (format ?: 'gtf').toString().toLowerCase() + if (!(normalized in ['gtf', 'gff', 'gff3'])) { + error "Unsupported OrionGeno format '${normalized}'. Use gtf or gff3." + } + def ext = normalized == 'gtf' ? 'gtf' : 'gff3' + def want_repeat = oriongenoBool(output_repeat) + + def n_bins = 8 + if (scatter_bins != null && scatter_bins.toString().trim()) { + n_bins = scatter_bins as int + } + if (mode == 'weighted' && n_bins < 1) { + error "OrionGeno weighted scatter requires --oriongeno_bins >= 1" + } + + genome + .map { meta, fasta -> + def sample = meta.id ?: fasta.baseName + [ + meta + [ + sample : sample, + oriongeno_format : ext, + oriongeno_output_repeat : want_repeat, + oriongeno_ext : ext, + ], + fasta, + ] + } + .set { ch_prepared } + + if (mode == 'none') { + ch_prepared + .map { meta, fasta -> + [meta + [id: meta.sample, chunk: 'all', order: 0], fasta] + } + .set { ch_pieces } + + ORIONGENO_PREDICT(ch_pieces, lin) + ch_versions = ch_versions.mix(ORIONGENO_PREDICT.out.versions) + + if (ext == 'gtf') { + ORIONGENO_PREDICT.out.annotation + .map { meta, anno -> [[id: meta.sample, sample: meta.sample], anno] } + .set { ch_gtf } + Channel.empty().set { ch_gff } + } else { + Channel.empty().set { ch_gtf } + ORIONGENO_PREDICT.out.annotation + .map { meta, anno -> [[id: meta.sample, sample: meta.sample], anno] } + .set { ch_gff } + } + + if (want_repeat && ext == 'gtf') { + ORIONGENO_PREDICT.out.repeats + .map { meta, rep -> [[id: meta.sample, sample: meta.sample], rep] } + .set { ch_repeat_gtf } + Channel.empty().set { ch_repeat_gff } + } else if (want_repeat) { + Channel.empty().set { ch_repeat_gtf } + ORIONGENO_PREDICT.out.repeats + .map { meta, rep -> [[id: meta.sample, sample: meta.sample], rep] } + .set { ch_repeat_gff } + } else { + Channel.empty().set { ch_repeat_gtf } + Channel.empty().set { ch_repeat_gff } + } + } else { + ORIONGENO_MANIFEST(ch_prepared, scatter_py) + ch_versions = ch_versions.mix(ORIONGENO_MANIFEST.out.versions) + + ORIONGENO_MANIFEST.out.manifest + .map { meta, man -> + def records = oriongenoParseManifest(man) + if (records.isEmpty()) { + error "No FASTA records found for ${meta.sample}" + } + if (mode == 'chromosome' && records.size() > 1000) { + error "OrionGeno chromosome scatter would launch ${records.size()} jobs for ${meta.sample}. Use --oriongeno_scatter weighted --oriongeno_bins N" + } + tuple(meta.sample, true) + } + .set { ch_scatter_ok } + + ch_prepared + .map { meta, fasta -> tuple(meta.sample, meta, fasta) } + .join(ch_scatter_ok) + .map { sample, meta, fasta, ok -> [meta + [headers: true], fasta] } + .set { ch_to_split } + + FXSPLIT(ch_to_split) + ch_versions = ch_versions.mix(FXSPLIT.out.versions) + + FXSPLIT.out.fastx + .mix(FXSPLIT.out.fastx_gz) + .map { meta, files -> tuple(meta.sample, meta, files instanceof List ? files : [files]) } + .join( + ORIONGENO_MANIFEST.out.manifest.map { meta, man -> tuple(meta.sample, man) } + ) + .flatMap { sample, meta, files, man -> + def records = oriongenoParseManifest(man) + if (records.isEmpty()) { + error "No FASTA records found for ${sample}" + } + if (mode == 'weighted') { + return oriongenoPackBins(records, n_bins).collect { bin -> + def chunk = "bin${(bin.order + 1).toString().padLeft(3, '0')}" + [ + meta + [ + id : "${meta.sample}.${chunk}", + chunk : chunk, + order : bin.order, + records: bin.records.collect { it.seqid }, + ], + bin.records.collect { rec -> oriongenoResolveRecord(rec.seqid, files) }, + ] + } + } + records.collect { rec -> + [ + meta + [ + id : "${meta.sample}.${rec.seqid}", + chunk : rec.seqid, + order : rec.order, + records: [rec.seqid], + ], + oriongenoResolveRecord(rec.seqid, files), + ] + } + } + .set { ch_split } + + if (mode == 'weighted') { + ORIONGENO_CAT(ch_split) + ch_versions = ch_versions.mix(ORIONGENO_CAT.out.versions) + ORIONGENO_CAT.out.fasta.set { ch_pieces } + } else { + ch_split.set { ch_pieces } + } + + ORIONGENO_PREDICT(ch_pieces, lin) + ch_versions = ch_versions.mix(ORIONGENO_PREDICT.out.versions) + + ORIONGENO_PREDICT.out.annotation + .map { meta, anno -> tuple(meta.sample, meta, anno) } + .groupTuple() + .map { sample, metas, annos -> + def paired = [metas, annos].transpose().sort { it[0].order } + [ + [ + id : sample, + sample : sample, + oriongeno_ext : ext, + ], + paired.collect { it[1] }, + ] + } + .map { meta, annos -> tuple(meta.id, meta, annos) } + .join( + ORIONGENO_MANIFEST.out.manifest.map { meta, man -> tuple(meta.sample, man) } + ) + .map { sample, meta, annos, man -> [meta, annos, man] } + .set { ch_gather_input } + + ORIONGENO_GATHER(ch_gather_input, scatter_py) + ch_versions = ch_versions.mix(ORIONGENO_GATHER.out.versions) + + if (ext == 'gtf') { + ORIONGENO_GATHER.out.annotation.set { ch_gtf } + Channel.empty().set { ch_gff } + } else { + Channel.empty().set { ch_gtf } + ORIONGENO_GATHER.out.annotation.set { ch_gff } + } + + if (want_repeat) { + ORIONGENO_PREDICT.out.repeats + .map { meta, rep -> tuple(meta.sample, meta, rep) } + .groupTuple() + .map { sample, metas, reps -> + def paired = [metas, reps].transpose().sort { it[0].order } + [ + [ + id : sample, + sample : sample, + oriongeno_ext : ext, + ], + paired.collect { it[1] }, + ] + } + .map { meta, reps -> tuple(meta.id, meta, reps) } + .join( + ORIONGENO_MANIFEST.out.manifest.map { meta, man -> tuple(meta.sample, man) } + ) + .map { sample, meta, reps, man -> [meta, reps, man] } + .set { ch_repeat_input } + + ORIONGENO_GATHER_REPEATS(ch_repeat_input, scatter_py) + ch_versions = ch_versions.mix(ORIONGENO_GATHER_REPEATS.out.versions) + + if (ext == 'gtf') { + ORIONGENO_GATHER_REPEATS.out.annotation.set { ch_repeat_gtf } + Channel.empty().set { ch_repeat_gff } + } else { + Channel.empty().set { ch_repeat_gtf } + ORIONGENO_GATHER_REPEATS.out.annotation.set { ch_repeat_gff } + } + } else { + Channel.empty().set { ch_repeat_gtf } + Channel.empty().set { ch_repeat_gff } + } + } + + emit: + gtf = ch_gtf + gff = ch_gff + repeat_gtf = ch_repeat_gtf + repeat_gff = ch_repeat_gff + versions = ch_versions +} diff --git a/subworkflows/oriongeno/oriongeno_scatter.py b/subworkflows/oriongeno/oriongeno_scatter.py new file mode 100644 index 0000000..6133c80 --- /dev/null +++ b/subworkflows/oriongeno/oriongeno_scatter.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Hiller Lab at the Senckenberg Gessellschaft für Naturforschung +# Distributed under the terms of the Apache License, Version 2.0. +"""FASTA manifest and OrionGeno chunk gather for the OrionGeno subworkflow. + +Each predict job renames genes from g1 / g1.t1 and repeats from r1. Gathering +concatenates chunks in FASTA order and renumbers those ids. +""" + +import sys + + +def write_manifest(fasta, output): + order = 0 + seqid = "" + length = 0 + with open(fasta, encoding="utf-8", newline="\n") as handle, open( + output, "w", encoding="utf-8", newline="\n" + ) as out: + out.write("order\tseqid\tlength\n") + for raw in handle: + line = raw.rstrip("\r\n") + if line.startswith(">"): + if seqid: + out.write(f"{order}\t{seqid}\t{length}\n") + order += 1 + token = line[1:].split() + seqid = token[0] if token else "" + length = 0 + continue + length += len(line) + if seqid: + out.write(f"{order}\t{seqid}\t{length}\n") + + +def manifest_order(path): + order = {} + with open(path, encoding="utf-8") as handle: + header = handle.readline() + if header.strip().split("\t") != ["order", "seqid", "length"]: + raise SystemExit(f"bad manifest header: {header!r}") + index = 0 + for raw in handle: + line = raw.rstrip("\n") + if not line: + continue + parts = line.split("\t", 2) + if len(parts) < 2 or not parts[1]: + raise SystemExit(f"bad manifest line: {line!r}") + if parts[1] not in order: + order[parts[1]] = index + index += 1 + return order + + +def gtf_value(text, key): + needle = key + ' "' + for part in text.split(";"): + part = part.strip() + if part.startswith(needle) and part.endswith('"'): + return part[len(needle):-1] + return "" + + +def set_gtf_value(text, key, value): + needle = key + ' "' + parts = [] + replaced = False + for part in text.split(";"): + raw = part.strip() + if not raw: + continue + if raw.startswith(needle) and raw.endswith('"'): + parts.append(f'{key} "{value}"') + replaced = True + else: + parts.append(raw) + if not replaced: + parts.append(f'{key} "{value}"') + return "; ".join(parts) + ";" + + +def gff_value(text, key): + needle = key + "=" + for part in text.split(";"): + if part.startswith(needle): + return part[len(needle):] + return "" + + +def map_identifier(value, pairs): + for old, new in pairs: + if value == old: + return new + if value.startswith(old + "."): + return new + value[len(old):] + return value + + +def set_gff_ids(text, pairs): + parts = [] + for part in text.split(";"): + if not part: + continue + if part.startswith("ID=") or part.startswith("Parent="): + key, value = part.split("=", 1) + parts.append(key + "=" + map_identifier(value, pairs)) + else: + parts.append(part) + return ";".join(parts) + + +def read_rows(path): + rows = [] + with open(path, encoding="utf-8") as handle: + for raw in handle: + line = raw.rstrip("\n") + if not line or line.startswith("#"): + continue + cols = line.split("\t") + if len(cols) >= 9: + rows.append(cols) + return rows + + +def parse_gtf_genes(path): + genes = [] + index = {} + for cols in read_rows(path): + if cols[2] == "repeat_region": + continue + gene_id = gtf_value(cols[8], "gene_id") + if not gene_id: + continue + if gene_id not in index: + index[gene_id] = { + "gene_id": gene_id, + "seqid": cols[0], + "start": int(cols[3]), + "end": int(cols[4]), + "lines": [], + "transcripts": [], + } + genes.append(index[gene_id]) + gene = index[gene_id] + if cols[2] == "gene": + gene["seqid"] = cols[0] + gene["start"] = int(cols[3]) + gene["end"] = int(cols[4]) + transcript_id = gtf_value(cols[8], "transcript_id") + if transcript_id and transcript_id not in gene["transcripts"]: + gene["transcripts"].append(transcript_id) + gene["lines"].append(cols) + return genes + + +def parse_gff_genes(path): + genes = [] + current = None + for cols in read_rows(path): + if cols[2] == "repeat_region": + continue + if cols[2] == "gene": + current = { + "gene_id": gff_value(cols[8], "ID"), + "seqid": cols[0], + "start": int(cols[3]), + "end": int(cols[4]), + "lines": [cols], + "transcripts": [], + } + genes.append(current) + continue + if current is None: + continue + if cols[2] in ("mRNA", "transcript"): + transcript_id = gff_value(cols[8], "ID") + if transcript_id and transcript_id not in current["transcripts"]: + current["transcripts"].append(transcript_id) + current["lines"].append(cols) + return genes + + +def sort_genes(genes, seq_order): + def key(gene): + return ( + seq_order.get(gene["seqid"], 10**12), + gene["start"], + gene["end"], + gene["seqid"], + ) + + return sorted(genes, key=key) + + +def write_genes(genes, output, kind): + with open(output, "w", encoding="utf-8", newline="\n") as handle: + if kind == "gff3": + handle.write("##gff-version 3\n") + for gene_number, gene in enumerate(genes, start=1): + new_gene = f"g{gene_number}" + pairs = [(gene["gene_id"], new_gene)] + for transcript_number, transcript_id in enumerate(gene["transcripts"], start=1): + pairs.append((transcript_id, f"g{gene_number}.t{transcript_number}")) + pairs.sort(key=lambda item: len(item[0]), reverse=True) + for cols in gene["lines"]: + if kind == "gtf": + text = cols[8] + text = set_gtf_value(text, "gene_id", new_gene) + transcript_id = gtf_value(cols[8], "transcript_id") + if transcript_id: + text = set_gtf_value(text, "transcript_id", map_identifier(transcript_id, pairs)) + cols[8] = text + else: + cols[8] = set_gff_ids(cols[8], pairs) + handle.write("\t".join(cols) + "\n") + + +def write_repeats(paths, output, kind, seq_order): + rows = [] + for path in paths: + rows.extend(read_rows(path)) + rows = [cols for cols in rows if cols[2] == "repeat_region"] + rows.sort( + key=lambda cols: ( + seq_order.get(cols[0], 10**12), + int(cols[3]), + int(cols[4]), + cols[6], + ) + ) + with open(output, "w", encoding="utf-8", newline="\n") as handle: + if kind == "gff3": + handle.write("##gff-version 3\n") + for number, cols in enumerate(rows, start=1): + if kind == "gtf": + cols[8] = f'repeat_id "r{number}";' + else: + cols[8] = f"ID=r{number}" + handle.write("\t".join(cols) + "\n") + + +def main(argv): + if len(argv) < 2: + raise SystemExit("usage: oriongeno_scatter.py manifest|genes|repeats ...") + command = argv[1] + if command == "manifest": + if len(argv) != 4: + raise SystemExit("usage: oriongeno_scatter.py manifest FASTA OUTPUT") + write_manifest(argv[2], argv[3]) + return + if command not in ("genes", "repeats"): + raise SystemExit(f"unknown command {command}") + if len(argv) < 5: + raise SystemExit(f"usage: oriongeno_scatter.py {command} gtf|gff3 MANIFEST OUTPUT [INPUT ...]") + kind = argv[2] + if kind not in ("gtf", "gff3"): + raise SystemExit(f"bad kind {kind}") + manifest = argv[3] + output = argv[4] + inputs = argv[5:] + seq_order = manifest_order(manifest) + if command == "genes": + parser = parse_gtf_genes if kind == "gtf" else parse_gff_genes + genes = [] + for path in inputs: + genes.extend(parser(path)) + write_genes(sort_genes(genes, seq_order), output, kind) + return + write_repeats(inputs, output, kind, seq_order) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/workflows/oriongeno/main.nf b/workflows/oriongeno/main.nf new file mode 100644 index 0000000..d73e2f2 --- /dev/null +++ b/workflows/oriongeno/main.nf @@ -0,0 +1,102 @@ +/* +Copyright (c) 2026 The Hiller Lab at the Senckenberg Gessellschaft für Naturforschung +Distributed under the terms of the Apache License, Version 2.0. +*/ + +/* +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + IMPORT LOCAL MODULES/SUBWORKFLOWS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +*/ + +include { ORIONGENO as RUN; oriongenoLineages; oriongenoScatterModes } from '../../subworkflows/oriongeno/main.nf' +include { GENOME } from '../../subworkflows/genome/main.nf' + +params.genome = null +params.lineage = null +params.species_name = null +params.checkpoint = null +params.oriongeno_scatter = 'chromosome' +params.oriongeno_bins = 8 +params.oriongeno_format = 'gtf' +params.oriongeno_output_repeat = false +params.oriongeno_extra_args = '' + +/* +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + WORKFLOWS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +*/ + +workflow ORIONGENO { + main: + if (!params.genome) { + error "Missing required --genome" + } + if (!params.lineage) { + error "Missing required --lineage" + } + + def lin = params.lineage.toString() + def lineages = oriongenoLineages() + if (!lineages.contains(lin)) { + error "Unsupported OrionGeno lineage '${lin}'. Choose one of: ${lineages.join(', ')}" + } + + def scatter = (params.oriongeno_scatter ?: 'chromosome').toString() + def scatter_modes = oriongenoScatterModes() + if (!scatter_modes.contains(scatter)) { + error "Unsupported OrionGeno scatter mode '${scatter}'. Choose one of: ${scatter_modes.join(', ')}" + } + + def format = (params.oriongeno_format ?: 'gtf').toString().toLowerCase() + if (!(format in ['gtf', 'gff', 'gff3'])) { + error "Unsupported OrionGeno format '${format}'. Use gtf or gff3." + } + + def bins = params.oriongeno_bins + if (bins == null || bins.toString().trim() == '') { + bins = 8 + } + if (scatter == 'weighted' && (bins as int) < 1) { + error "OrionGeno weighted scatter requires --oriongeno_bins >= 1" + } + + GENOME(params.genome) + + GENOME.out.genome + .map { fasta -> + def meta = [ + id : fasta.baseName, + oriongeno_args: params.oriongeno_extra_args ?: '', + ] + if (params.species_name) { + meta.species_name = params.species_name.toString() + } + if (params.checkpoint) { + meta.checkpoint = params.checkpoint.toString() + } + [meta, fasta] + } + .set { ch_input } + + RUN( + ch_input, + lin, + scatter, + bins, + format, + params.oriongeno_output_repeat + ) + + emit: + gtf = RUN.out.gtf + gff = RUN.out.gff + repeat_gtf = RUN.out.repeat_gtf + repeat_gff = RUN.out.repeat_gff + versions = RUN.out.versions +} + +workflow { + ORIONGENO() +}