Repository: MultiQC/MultiQC (language: python)

Issue:
UMICollapse
### Name of the tool

UMICollapse

### Tool homepage

https://github.com/Daniel-Liu-c0deb0t/UMICollapse

### Tool description

Algorithms for efficiently collapsing reads with Unique Molecular Identifiers

### Tool output

[SRR19887568.umi_dedup.sorted_UMICollapse.log](https://github.com/user-attachments/files/16881147/SRR19887568.umi_dedup.sorted_UMICollapse.log)


### Log filename pattern

_UMICollapse.log

### Data suitable for MultiQC plot(s)

_No response_

### Most interesting data for the General Stats table

_No response_

### Before submitting

- [X] I have included example data (zipped, not pasted) that can be used to write the module.


Discussion / hints:

Fix patch:
diff --git a/multiqc/config_defaults.yaml b/multiqc/config_defaults.yaml
index c6d20db94..f6a44c772 100644
--- a/multiqc/config_defaults.yaml
+++ b/multiqc/config_defaults.yaml
@@ -468,6 +468,7 @@ module_order:
   - stacks
   - varscan2
   - snippy
+  - umicollapse
   - umitools
   - truvari
   - megahit
diff --git a/multiqc/modules/umicollapse/__init__.py b/multiqc/modules/umicollapse/__init__.py
new file mode 100644
index 000000000..8740bc232
--- /dev/null
+++ b/multiqc/modules/umicollapse/__init__.py
@@ -0,0 +1,3 @@
+from .umicollapse import MultiqcModule
+
+__all__ = ["MultiqcModule"]
diff --git a/multiqc/modules/umicollapse/umicollapse.py b/multiqc/modules/umicollapse/umicollapse.py
new file mode 100644
index 000000000..b7b40b35a
--- /dev/null
+++ b/multiqc/modules/umicollapse/umicollapse.py
@@ -0,0 +1,213 @@
+import logging
+import re
+from typing import Dict, Union
+
+from multiqc import config
+from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
+from multiqc.plots import bargraph, violin
+
+log = logging.getLogger(__name__)
+
+
+class MultiqcModule(BaseMultiqcModule):
+    """
+    Sample names are extracted from log files if possible. In logs, the command
+    line arguments are printed, which must have both the input and output
+    file paths.
+
+    ```
+    umicollapse bam -i SRR19887568.sorted.bam -o SRR19887568.umi_dedup.sorted.bam
+    Arguments	[bam, -i, SRR19887568.sorted.bam, -o, SRR19887568.umi_dedup.sorted.bam]
+    ```
+
+    `umicollapse` requires both -i and -o flags as valid file paths. Process
+    substitution is not supported currently by umicollapse, but in case it is
+    used for the -i flag, we fallback to the log file name.
+    """
+
+    def __init__(self):
+        super(MultiqcModule, self).__init__(
+            name="UMICollapse",
+            anchor="umicollapse",
+            href="https://github.com/Daniel-Liu-c0deb0t/UMICollapse",
+            info="Algorithms for efficiently collapsing reads with Unique Molecular Identifiers",
+            doi="10.7717/peerj.8275",
+        )
+
+        data_by_sample = dict()
+        for f in self.find_log_files("umicollapse"):
+            # Parse the log file for sample name and statistics
+            data = self.parse_logs(f)
+            if data:
+                f["s_name"] = self._parse_s_name(f)
+                if f["s_name"] in data_by_sample:
+                    log.debug(f"Duplicate sample name found! Overwriting: {f['s_name']}")
+                data_by_sample[f["s_name"]] = data
+                self.add_data_source(f)  # Add the log file information to the multiqc_sources.txt
+                if "version" in data:  # Add version info
+                    self.add_software_version(data.pop("version"), f["s_name"])
+
+        # Check the log files against the user supplied list of samples to ignore
+        data_by_sample = self.ignore_samples(data_by_sample)
+        if len(data_by_sample) == 0 and len(data_by_sample) == 0:
+            raise ModuleNoSamplesFound
+        log.info(f"Found {len(data_by_sample)} UMI collapse reports")
+
+        # Write parsed reports data to a file
+        self.write_data_file(data_by_sample, "multiqc_umicollapse")
+
+        if data_by_sample:
+            self.general_stats_table(data_by_sample)
+            self.bar_plot(data_by_sample)
+            self.violin_plot(data_by_sample)
+
+    def _parse_s_name(self, f) -> str:
+        in_m = re.search(r"Arguments\t\[(\S+, )+-i, ([^,]+)", f["f"])
+        if in_m:
+            in_name = in_m.group(2)
+            return self.clean_s_name(in_name)
+
+        out_m = re.search(r"Arguments\t\[(\S+, )+-o, ([^,]+)", f["f"])
+        if out_m:
+            out_name = out_m.group(2)
+            return self.clean_s_name(out_name)
+
+        return f["s_name"]
+
+    @staticmethod
+    def parse_logs(f) -> Dict:
+        regexes = [
+            (int, "input_reads", r"Number of input reads\t(\d+)"),
+            (int, "dedup_input_reads", r"Number of unremoved reads\t(\d+)"),
+            (int, "positions_deduplicated", r"Number of unique alignment positions\t(\d+)"),
+            (float, "mean_umi_per_pos", r"Average number of UMIs per alignment position\t([\d\.]+)"),
+            (int, "max_umi_per_pos", r"Max number of UMIs over all alignment positions\t(\d+)"),
+            (int, "dedup_output_reads", r"Number of reads after deduplicating\t(\d+)"),
+        ]
+
+        data: Dict[str, Union[str, int, float]] = {}
+        # Search for values using regular expressions
+        for type_, key, regex in regexes:
+            re_matches = re.search(regex, f["f"])
+            if re_matches:
+                data[key] = type_(re_matches.group(1))
+
+        # Calculate a few simple supplementary stats
+        try:
+            data["dedup_percent_passing"] = round(((data["dedup_output_reads"] / data["dedup_input_reads"]) * 100.0), 2)
+        except (KeyError, ZeroDivisionError):
+            pass
+        try:
+            data["removed_reads"] = data["input_reads"] - data["dedup_input_reads"]
+        except KeyError:
+            pass
+        try:
+            data["dedup_removed_reads"] = data["dedup_input_reads"] - data["dedup_output_reads"]
+        except KeyError:
+            pass
+
+        return data
+
+    def general_stats_table(self, data_by_sample):
+        """
+        Take the parsed stats from the umicollapse report and add it to the
+        basic stats table at the top of the report
+        """
+        headers = {
+            "dedup_output_reads": {
+                "title": "Unique Reads",
+                "description": f"Reads remaining after deduplication ({config.read_count_desc})",
+                "min": 0,
+                "modify": lambda x: x * config.read_count_multiplier,
+                "shared_key": "read_count",
+                "scale": "PuRd",
+            },
+            "dedup_percent_passing": {
+                "title": "Pass Dedup",
+                "description": "% processed reads that passed deduplication",
+                "max": 100,
+                "min": 0,
+                "suffix": "%",
+                "scale": "RdYlGn",
+            },
+        }
+        self.general_stats_addcols(data_by_sample, headers)
+
+    def bar_plot(self, data_by_sample):
+        """Generate a plot of the read deduplication rates for the main report"""
+
+        # Specify the order of the different possible categories
+        keys = {
+            "dedup_output_reads": {"color": "#7fc97f", "name": "Reads remaining"},
+            "dedup_removed_reads": {"color": "#fdc086", "name": "Reads removed"},
+        }
+
+        return self.add_section(
+            name="Deduplicated Reads",
+            anchor="umicollapse-dedup-plot",
+            plot=bargraph.plot(
+                data_by_sample,
+                keys,
+                {
+                    "id": "umicollapse_deduplication_barplot",
+                    "title": "UMI-tools: Deduplication Counts",
+                    "ylab": "# Reads",
+                    "cpswitch_counts_label": "Number of Reads",
+                },
+            ),
+        )
+
+    def violin_plot(self, data_by_sample):
+        """Generate a violin of UMI stats for the main report"""
+
+        headers = {
+            "positions_deduplicated": {
+                "title": "Positions Dedup",
+                "description": "Total number of positions deduplicated",
+                "min": 0,
+                "format": "{:,d}",
+                "scale": "Greens",
+            },
+            "mean_umi_per_pos": {
+                "title": "Mean #UMI",
+                "description": "Mean number of unique UMIs per position",
+                "min": 0,
+                "format": "{:,.2f}",
+                "scale": "Reds",
+            },
+            "max_umi_per_pos": {
+                "title": "Max #UMI",
+                "description": "Max number of unique UMIs per position",
+                "min": 0,
+                "format": "{:,.0f}",
+                "scale": "Oranges",
+            },
+            "removed_reads": {
+                "title": "Discarded reads",
+                "description": "Discarded unmapped, unpaired, or chimeric reads",
+                "min": 0,
+                "format": "{:,d}",
+                "scale": "Blues",
+            },
+        }
+
+        # add a section with a violin plot of UMI stats to the report
+        self.add_section(
+            name="UMI Stats",
+            anchor="umicollapse-umi-stats",
+            description="Statistics from running `umicollapse`",
+            helptext="""
+            - **Positions Dedup**: Total number of positions deduplicated
+            - **Mean #UMI**: Mean number of unique UMIs per position
+            - **Max #UMI**: Max number of unique UMIs per position
+            - **Discarded reads**: Discarded unmapped, unpaired, or chimeric reads
+            """,
+            plot=violin.plot(
+                data_by_sample,
+                headers,
+                {
+                    "id": "umicollapse_stats_violin",
+                    "title": "UMICollapse: UMI stats",
+                },
+            ),
+        )
diff --git a/multiqc/search_patterns.yaml b/multiqc/search_patterns.yaml
index 51e0fa8a1..dbf4f9068 100644
--- a/multiqc/search_patterns.yaml
+++ b/multiqc/search_patterns.yaml
@@ -950,6 +950,10 @@ truvari/bench:
   contents_re: ".*truvari.* bench.*"
   fn: "log.txt"
   num_lines: 2
+umicollapse:
+  fn: "*_UMICollapse.log"
+  num_lines: 100
+  contents: "UMI collapsing finished in "
 umitools/extract:
   contents: "# output generated by extract"
   num_lines: 3
diff --git a/pyproject.toml b/pyproject.toml
index 50c36bd8e..6ddf3c28b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -228,6 +228,7 @@ theta2 = "multiqc.modules.theta2:MultiqcModule"
 tophat = "multiqc.modules.tophat:MultiqcModule"
 trimmomatic = "multiqc.modules.trimmomatic:MultiqcModule"
 truvari = "multiqc.modules.truvari:MultiqcModule"
+umicollapse = "multiqc.modules.umicollapse:MultiqcModule"
 umitools = "multiqc.modules.umitools:MultiqcModule"
 varscan2 = "multiqc.modules.varscan2:MultiqcModule"
 vcftools = "multiqc.modules.vcftools:MultiqcModule"


Test patch:
diff --git a/multiqc/modules/umicollapse/tests/__init__.py b/multiqc/modules/umicollapse/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/multiqc/modules/umicollapse/tests/test_umicollapse.py b/multiqc/modules/umicollapse/tests/test_umicollapse.py
new file mode 100644
index 000000000..fcf991b94
--- /dev/null
+++ b/multiqc/modules/umicollapse/tests/test_umicollapse.py
@@ -0,0 +1,59 @@
+from multiqc import report
+from multiqc.modules.umicollapse import MultiqcModule
+
+
+def test_parse(tmp_path):
+    # File without a file name or content match.
+    f1 = tmp_path / "SAMPLE.log"
+    f1.write_text("Irrelevant file")
+
+    # File without file name match.
+    f2 = tmp_path / "SAMPLE.log"
+    f2.write_text("UMI collapsing finished in 1077.717 seconds!")
+
+    # File with match.
+    f3 = tmp_path / "SRR19887568.umi_dedup.sorted_UMICollapse.log"
+    f3.write_text("""\
+Arguments	[bam, -i, SRR19887568.sorted.bam, -o, SRR19887568.umi_dedup.sorted.bam, --paired, --two-pass, --remove-unpaired, --remove-chimeric, --algo, dir]
+Number of input reads	53490614
+Number of removed unmapped reads	24260
+Number of unpaired reads	0
+Number of chimeric reads	0
+Number of unremoved reads	53466354
+Number of unique alignment positions	21532282
+Average number of UMIs per alignment position	1.3478738110526325
+Max number of UMIs over all alignment positions	165
+Number of reads after deduplicating	27208411
+UMI collapsing finished in 1077.717 seconds!
+""")
+
+    report.analysis_files = [f1, f2, f3]
+    report.search_files(["umicollapse"])
+    m = MultiqcModule()
+    assert len(m.saved_raw_data) == 1
+    print(m.saved_raw_data)
+
+    data = m.saved_raw_data["multiqc_umicollapse"]
+
+    keys = data.keys()
+    assert len(keys) == 1
+    assert list(keys)[0] == "SRR19887568"
+
+    assert len(m.sections) == 2
+    assert m.sections[0].name == "Deduplicated Reads"
+    assert m.sections[1].name == "UMI Stats"
+
+    assert len(report.general_stats_data) > 0
+    assert report.general_stats_data[0] == {
+        "SRR19887568": {
+            "input_reads": 53490614,
+            "dedup_input_reads": 53466354,
+            "dedup_output_reads": 27208411,
+            "dedup_percent_passing": 50.89,
+            "dedup_removed_reads": 26257943,
+            "positions_deduplicated": 21532282,
+            "mean_umi_per_pos": 1.3478738110526325,
+            "max_umi_per_pos": 165,
+            "removed_reads": 24260,
+        }
+    }