cstag.to_mutation_percentages

  1from __future__ import annotations
  2
  3import re
  4from collections import defaultdict
  5from collections.abc import Mapping, Sequence
  6from pathlib import Path
  7from typing import Any
  8
  9import matplotlib.pyplot as plt
 10from matplotlib.colors import is_color_like, to_rgb
 11from matplotlib.ticker import MaxNLocator
 12
 13CS_PREFIX = "cs:Z:"
 14CS_TOKEN_PATTERN = re.compile(
 15    r"=[ACGTN]+|:[0-9]+|\*[acgtn][acgtn]|\+[acgtn]+|-[acgtn]+"
 16)
 17PROFILE_KEYS = (
 18    "position",
 19    "coverage",
 20    "total_pct",
 21    "insertion_pct",
 22    "deletion_pct",
 23    "substitution_pct",
 24)
 25REGION_KEYS = ("name", "start", "end", "color")
 26
 27
 28def _percentage(reads: set[int], coverage_count: int) -> float:
 29    return 100.0 * len(reads) / coverage_count
 30
 31
 32def _tokenize_cs(cs_tag: str, tag_index: int) -> list[str]:
 33    """Validate and tokenize one minimap2 short- or long-form cs tag."""
 34    if not isinstance(cs_tag, str):
 35        raise TypeError(f"cs_tags[{tag_index}] must be a string")
 36
 37    if cs_tag.startswith(CS_PREFIX):
 38        normalized = cs_tag[len(CS_PREFIX) :]
 39        if CS_PREFIX in normalized:
 40            raise ValueError(
 41                f"Invalid cs tag at index {tag_index}: repeated cs:Z: prefix"
 42            )
 43    else:
 44        if CS_PREFIX in cs_tag:
 45            raise ValueError(
 46                f"Invalid cs tag at index {tag_index}: cs:Z: must be at the start"
 47            )
 48        normalized = cs_tag
 49
 50    if not normalized:
 51        raise ValueError(f"Invalid cs tag at index {tag_index}: tag is empty")
 52    if "~" in normalized:
 53        raise ValueError(
 54            f"cs_tags[{tag_index}] contains unsupported splice operation '~'"
 55        )
 56
 57    tokens = CS_TOKEN_PATTERN.findall(normalized)
 58    if "".join(tokens) != normalized:
 59        raise ValueError(f"Invalid cs tag at index {tag_index}: {cs_tag}")
 60    if any(token.startswith(":") for token in tokens) and any(
 61        token.startswith("=") for token in tokens
 62    ):
 63        raise ValueError(f"cs_tags[{tag_index}] mixes short and long match operations")
 64    return tokens
 65
 66
 67def _events_for_tag(
 68    cs_tag: str, tag_index: int
 69) -> tuple[set[int], dict[str, set[int]]]:
 70    """Return covered positions and per-category event positions for one tag."""
 71    tokens = _tokenize_cs(cs_tag, tag_index)
 72    reference_position = 1
 73    coverage: set[int] = set()
 74    events: dict[str, set[int]] = {
 75        "insertion": set(),
 76        "deletion": set(),
 77        "substitution": set(),
 78    }
 79
 80    for token in tokens:
 81        operation = token[0]
 82        if operation in {":", "="}:
 83            length = int(token[1:]) if operation == ":" else len(token) - 1
 84            covered_positions = range(reference_position, reference_position + length)
 85            coverage.update(covered_positions)
 86            reference_position += length
 87        elif operation == "*":
 88            coverage.add(reference_position)
 89            events["substitution"].add(reference_position)
 90            reference_position += 1
 91        elif operation == "+":
 92            anchor_position = reference_position - 1
 93            if anchor_position < 1 or anchor_position not in coverage:
 94                raise ValueError(
 95                    f"cs_tags[{tag_index}] starts with an insertion that has "
 96                    "no left reference anchor"
 97                )
 98            events["insertion"].add(anchor_position)
 99        elif operation == "-":
100            length = len(token) - 1
101            deleted_positions = set(
102                range(reference_position, reference_position + length)
103            )
104            coverage.update(deleted_positions)
105            events["deletion"].update(deleted_positions)
106            reference_position += length
107
108    if not coverage:
109        raise ValueError(f"cs_tags[{tag_index}] has a reference span of zero")
110    return coverage, events
111
112
113def summarize_cs(cs_tags: Sequence[str]) -> list[dict[str, int | float]]:
114    """Calculate mutation percentages at each relative reference position.
115
116    Short- and long-form cs tags are accepted, with or without the ``cs:Z:``
117    prefix. All tags are assumed to begin at relative reference position 1.
118    Each percentage uses the number of tags covering that position as its
119    denominator. ``total_pct`` is the union of reads with any mutation at the
120    position, so it cannot exceed 100 percent.
121
122    Args:
123        cs_tags: Non-empty sequence of minimap2 cs tag strings.
124
125    Returns:
126        One dictionary per 1-based reference position. Each dictionary contains
127        ``position``, ``coverage``, ``total_pct``, ``insertion_pct``,
128        ``deletion_pct``, and ``substitution_pct``.
129    """
130    if isinstance(cs_tags, (str, bytes)) or not isinstance(cs_tags, Sequence):
131        raise TypeError("cs_tags must be a non-empty sequence of strings")
132    if not cs_tags:
133        raise ValueError("cs_tags must not be empty")
134
135    coverage_reads: dict[int, set[int]] = defaultdict(set)
136    event_reads: dict[str, dict[int, set[int]]] = {
137        "insertion": defaultdict(set),
138        "deletion": defaultdict(set),
139        "substitution": defaultdict(set),
140    }
141
142    for tag_index, cs_tag in enumerate(cs_tags):
143        coverage, events = _events_for_tag(cs_tag, tag_index)
144        for position in coverage:
145            coverage_reads[position].add(tag_index)
146        for category, positions in events.items():
147            for position in positions:
148                event_reads[category][position].add(tag_index)
149
150    max_position = max(coverage_reads)
151    profile: list[dict[str, int | float]] = []
152    for position in range(1, max_position + 1):
153        covered = coverage_reads[position]
154        coverage_count = len(covered)
155        insertion_reads = event_reads["insertion"][position]
156        deletion_reads = event_reads["deletion"][position]
157        substitution_reads = event_reads["substitution"][position]
158        total_reads = insertion_reads | deletion_reads | substitution_reads
159
160        profile.append(
161            {
162                "position": position,
163                "coverage": coverage_count,
164                "total_pct": _percentage(total_reads, coverage_count),
165                "insertion_pct": _percentage(insertion_reads, coverage_count),
166                "deletion_pct": _percentage(deletion_reads, coverage_count),
167                "substitution_pct": _percentage(substitution_reads, coverage_count),
168            }
169        )
170    return profile
171
172
173def _validate_regions(
174    regions: Sequence[Mapping[str, object]] | None, max_position: int
175) -> list[dict[str, str | int]]:
176    """Validate optional 1-based inclusive region annotations."""
177    if regions is None:
178        return []
179    if isinstance(regions, (str, bytes)) or not isinstance(regions, Sequence):
180        raise TypeError("regions must be a sequence of dictionaries or None")
181
182    validated: list[dict[str, str | int]] = []
183    for region_index, region in enumerate(regions):
184        if not isinstance(region, Mapping):
185            raise TypeError(f"regions[{region_index}] must be a dictionary")
186        missing = set(REGION_KEYS) - set(region)
187        if missing:
188            missing_list = ", ".join(sorted(missing))
189            raise ValueError(f"regions[{region_index}] is missing keys: {missing_list}")
190
191        name = region["name"]
192        start = region["start"]
193        end = region["end"]
194        color = region["color"]
195        if not isinstance(name, str) or not name:
196            raise ValueError(
197                f"regions[{region_index}]['name'] must be a non-empty string"
198            )
199        if isinstance(start, bool) or not isinstance(start, int):
200            raise TypeError(f"regions[{region_index}]['start'] must be an integer")
201        if isinstance(end, bool) or not isinstance(end, int):
202            raise TypeError(f"regions[{region_index}]['end'] must be an integer")
203        if not 1 <= start <= end <= max_position:
204            raise ValueError(
205                f"regions[{region_index}] must satisfy "
206                f"1 <= start <= end <= {max_position}"
207            )
208        if not isinstance(color, str) or not is_color_like(color):
209            raise ValueError(f"regions[{region_index}]['color'] is not a valid color")
210
211        validated.append({"name": name, "start": start, "end": end, "color": color})
212    return validated
213
214
215def plot_mutation_percentages(
216    records: Sequence[dict[str, int | float]],
217    output_path: Path | None = None,
218    regions: Sequence[Mapping[str, object]] | None = None,
219) -> tuple[Any, Any]:
220    """Create four mutation plots with optional region highlights."""
221    if isinstance(records, (str, bytes)) or not isinstance(records, Sequence):
222        raise TypeError("records must be a non-empty sequence of dictionaries")
223    if not records:
224        raise ValueError("records must not be empty")
225    if output_path is not None and not isinstance(output_path, Path):
226        raise TypeError("output_path must be a pathlib.Path or None")
227
228    for record_index, record in enumerate(records):
229        if not isinstance(record, dict):
230            raise TypeError(f"records[{record_index}] must be a dictionary")
231        missing = set(PROFILE_KEYS) - set(record)
232        if missing:
233            missing_list = ", ".join(sorted(missing))
234            raise ValueError(f"records[{record_index}] is missing keys: {missing_list}")
235
236    positions = [record["position"] for record in records]
237    series = (
238        ("total_pct", "Total mutations", "#1f77b4"),
239        ("insertion_pct", "Insertions", "#ff7f0e"),
240        ("deletion_pct", "Deletions", "#d62728"),
241        ("substitution_pct", "Substitutions", "#2ca02c"),
242    )
243    validated_regions = _validate_regions(regions, int(positions[-1]))
244    figure, axes = plt.subplots(
245        4,
246        1,
247        figsize=(12, 10),
248        sharex=True,
249        constrained_layout=True,
250    )
251
252    for axis, (key, title, color) in zip(axes, series, strict=True):
253        values = [record[key] for record in records]
254        axis.bar(
255            positions,
256            values,
257            width=0.8,
258            align="center",
259            color=color,
260            edgecolor="none",
261            alpha=0.85,
262            zorder=3,
263        )
264        for region_index, region in enumerate(validated_regions):
265            start = int(region["start"])
266            end = int(region["end"])
267            region_color = str(region["color"])
268            highlight = axis.axvspan(
269                start - 0.5,
270                end + 0.5,
271                color=region_color,
272                alpha=0.18,
273                linewidth=0,
274                zorder=0.5,
275            )
276            highlight.set_gid(f"cstag-region-{region_index}")
277            red, green, blue = to_rgb(region_color)
278            axis.text(
279                (start + end) / 2,
280                0.97,
281                str(region["name"]),
282                transform=axis.get_xaxis_transform(),
283                ha="center",
284                va="top",
285                color=(red * 0.65, green * 0.65, blue * 0.65),
286                fontsize="small",
287                clip_on=True,
288                zorder=3,
289            )
290        axis.set_title(title)
291        axis.set_ylabel("Mutation (%)")
292        axis.set_ylim(0, 100)
293        axis.grid(axis="both", alpha=0.25)
294
295    axes[-1].set_xlabel("Reference position (1-based)")
296    axes[-1].set_xlim(positions[0] - 0.5, positions[-1] + 0.5)
297    if len(positions) <= 20:
298        axes[-1].set_xticks(positions)
299    else:
300        axes[-1].xaxis.set_major_locator(MaxNLocator(integer=True))
301
302    if output_path is not None:
303        output_path.parent.mkdir(parents=True, exist_ok=True)
304        if output_path.suffix.lower() == ".pdf":
305            # Keep plot elements vector-based and embed editable TrueType text.
306            with plt.rc_context({"pdf.fonttype": 42}):
307                figure.savefig(output_path, bbox_inches="tight")
308        else:
309            figure.savefig(output_path, dpi=150, bbox_inches="tight")
310
311    return figure, axes
312
313
314def to_mutation_percentages(
315    cs_tags: Sequence[str],
316    output_path: Path,
317    regions: Sequence[Mapping[str, object]] | None = None,
318) -> list[dict[str, int | float]]:
319    """Report and plot mutation percentages at each reference position.
320
321    Args:
322        cs_tags: Python sequence containing short- or long-form cs tag strings.
323        output_path: Path of the plot file to create. The format is inferred
324            from its extension. Use ``.pdf`` for an editable vector PDF with
325            embedded TrueType fonts, or ``.png`` for a raster image.
326        regions: Optional sequence of dictionaries describing regions to highlight
327            in the mutation plots. Each dictionary must contain ``name``
328            (the displayed label), ``start`` and ``end`` (1-based, inclusive
329            reference positions), and ``color`` (a Matplotlib-compatible color).
330            For example::
331
332                [
333                    {"name": "crRNA", "start": 80, "end": 99,
334                     "color": "lightblue"},
335                    {"name": "index", "start": 207, "end": 214,
336                     "color": "lightgreen"},
337                ]
338
339            Each region is drawn as a translucent vertical band behind the data
340            in all four mutation panels, with its name shown at the top of the
341            band. Overlapping regions remain visible through blended colors.
342
343    Returns:
344        One dictionary per 1-based relative reference position containing
345        coverage and total, insertion, deletion, and substitution percentages.
346    """
347    if not isinstance(output_path, Path):
348        raise TypeError("output_path must be a pathlib.Path")
349
350    profile = summarize_cs(cs_tags)
351    figure, _ = plot_mutation_percentages(profile, output_path, regions=regions)
352    plt.close(figure)
353    return profile
CS_PREFIX = 'cs:Z:'
CS_TOKEN_PATTERN = re.compile('=[ACGTN]+|:[0-9]+|\\*[acgtn][acgtn]|\\+[acgtn]+|-[acgtn]+')
PROFILE_KEYS = ('position', 'coverage', 'total_pct', 'insertion_pct', 'deletion_pct', 'substitution_pct')
REGION_KEYS = ('name', 'start', 'end', 'color')
def summarize_cs(cs_tags: Sequence[str]) -> list[dict[str, int | float]]:
114def summarize_cs(cs_tags: Sequence[str]) -> list[dict[str, int | float]]:
115    """Calculate mutation percentages at each relative reference position.
116
117    Short- and long-form cs tags are accepted, with or without the ``cs:Z:``
118    prefix. All tags are assumed to begin at relative reference position 1.
119    Each percentage uses the number of tags covering that position as its
120    denominator. ``total_pct`` is the union of reads with any mutation at the
121    position, so it cannot exceed 100 percent.
122
123    Args:
124        cs_tags: Non-empty sequence of minimap2 cs tag strings.
125
126    Returns:
127        One dictionary per 1-based reference position. Each dictionary contains
128        ``position``, ``coverage``, ``total_pct``, ``insertion_pct``,
129        ``deletion_pct``, and ``substitution_pct``.
130    """
131    if isinstance(cs_tags, (str, bytes)) or not isinstance(cs_tags, Sequence):
132        raise TypeError("cs_tags must be a non-empty sequence of strings")
133    if not cs_tags:
134        raise ValueError("cs_tags must not be empty")
135
136    coverage_reads: dict[int, set[int]] = defaultdict(set)
137    event_reads: dict[str, dict[int, set[int]]] = {
138        "insertion": defaultdict(set),
139        "deletion": defaultdict(set),
140        "substitution": defaultdict(set),
141    }
142
143    for tag_index, cs_tag in enumerate(cs_tags):
144        coverage, events = _events_for_tag(cs_tag, tag_index)
145        for position in coverage:
146            coverage_reads[position].add(tag_index)
147        for category, positions in events.items():
148            for position in positions:
149                event_reads[category][position].add(tag_index)
150
151    max_position = max(coverage_reads)
152    profile: list[dict[str, int | float]] = []
153    for position in range(1, max_position + 1):
154        covered = coverage_reads[position]
155        coverage_count = len(covered)
156        insertion_reads = event_reads["insertion"][position]
157        deletion_reads = event_reads["deletion"][position]
158        substitution_reads = event_reads["substitution"][position]
159        total_reads = insertion_reads | deletion_reads | substitution_reads
160
161        profile.append(
162            {
163                "position": position,
164                "coverage": coverage_count,
165                "total_pct": _percentage(total_reads, coverage_count),
166                "insertion_pct": _percentage(insertion_reads, coverage_count),
167                "deletion_pct": _percentage(deletion_reads, coverage_count),
168                "substitution_pct": _percentage(substitution_reads, coverage_count),
169            }
170        )
171    return profile

Calculate mutation percentages at each relative reference position.

Short- and long-form cs tags are accepted, with or without the cs:Z: prefix. All tags are assumed to begin at relative reference position 1. Each percentage uses the number of tags covering that position as its denominator. total_pct is the union of reads with any mutation at the position, so it cannot exceed 100 percent.

Args: cs_tags: Non-empty sequence of minimap2 cs tag strings.

Returns: One dictionary per 1-based reference position. Each dictionary contains position, coverage, total_pct, insertion_pct, deletion_pct, and substitution_pct.

def plot_mutation_percentages( records: Sequence[dict[str, int | float]], output_path: pathlib.Path | None = None, regions: Sequence[Mapping[str, object]] | None = None) -> tuple[typing.Any, typing.Any]:
216def plot_mutation_percentages(
217    records: Sequence[dict[str, int | float]],
218    output_path: Path | None = None,
219    regions: Sequence[Mapping[str, object]] | None = None,
220) -> tuple[Any, Any]:
221    """Create four mutation plots with optional region highlights."""
222    if isinstance(records, (str, bytes)) or not isinstance(records, Sequence):
223        raise TypeError("records must be a non-empty sequence of dictionaries")
224    if not records:
225        raise ValueError("records must not be empty")
226    if output_path is not None and not isinstance(output_path, Path):
227        raise TypeError("output_path must be a pathlib.Path or None")
228
229    for record_index, record in enumerate(records):
230        if not isinstance(record, dict):
231            raise TypeError(f"records[{record_index}] must be a dictionary")
232        missing = set(PROFILE_KEYS) - set(record)
233        if missing:
234            missing_list = ", ".join(sorted(missing))
235            raise ValueError(f"records[{record_index}] is missing keys: {missing_list}")
236
237    positions = [record["position"] for record in records]
238    series = (
239        ("total_pct", "Total mutations", "#1f77b4"),
240        ("insertion_pct", "Insertions", "#ff7f0e"),
241        ("deletion_pct", "Deletions", "#d62728"),
242        ("substitution_pct", "Substitutions", "#2ca02c"),
243    )
244    validated_regions = _validate_regions(regions, int(positions[-1]))
245    figure, axes = plt.subplots(
246        4,
247        1,
248        figsize=(12, 10),
249        sharex=True,
250        constrained_layout=True,
251    )
252
253    for axis, (key, title, color) in zip(axes, series, strict=True):
254        values = [record[key] for record in records]
255        axis.bar(
256            positions,
257            values,
258            width=0.8,
259            align="center",
260            color=color,
261            edgecolor="none",
262            alpha=0.85,
263            zorder=3,
264        )
265        for region_index, region in enumerate(validated_regions):
266            start = int(region["start"])
267            end = int(region["end"])
268            region_color = str(region["color"])
269            highlight = axis.axvspan(
270                start - 0.5,
271                end + 0.5,
272                color=region_color,
273                alpha=0.18,
274                linewidth=0,
275                zorder=0.5,
276            )
277            highlight.set_gid(f"cstag-region-{region_index}")
278            red, green, blue = to_rgb(region_color)
279            axis.text(
280                (start + end) / 2,
281                0.97,
282                str(region["name"]),
283                transform=axis.get_xaxis_transform(),
284                ha="center",
285                va="top",
286                color=(red * 0.65, green * 0.65, blue * 0.65),
287                fontsize="small",
288                clip_on=True,
289                zorder=3,
290            )
291        axis.set_title(title)
292        axis.set_ylabel("Mutation (%)")
293        axis.set_ylim(0, 100)
294        axis.grid(axis="both", alpha=0.25)
295
296    axes[-1].set_xlabel("Reference position (1-based)")
297    axes[-1].set_xlim(positions[0] - 0.5, positions[-1] + 0.5)
298    if len(positions) <= 20:
299        axes[-1].set_xticks(positions)
300    else:
301        axes[-1].xaxis.set_major_locator(MaxNLocator(integer=True))
302
303    if output_path is not None:
304        output_path.parent.mkdir(parents=True, exist_ok=True)
305        if output_path.suffix.lower() == ".pdf":
306            # Keep plot elements vector-based and embed editable TrueType text.
307            with plt.rc_context({"pdf.fonttype": 42}):
308                figure.savefig(output_path, bbox_inches="tight")
309        else:
310            figure.savefig(output_path, dpi=150, bbox_inches="tight")
311
312    return figure, axes

Create four mutation plots with optional region highlights.

def to_mutation_percentages( cs_tags: Sequence[str], output_path: pathlib.Path, regions: Sequence[Mapping[str, object]] | None = None) -> list[dict[str, int | float]]:
315def to_mutation_percentages(
316    cs_tags: Sequence[str],
317    output_path: Path,
318    regions: Sequence[Mapping[str, object]] | None = None,
319) -> list[dict[str, int | float]]:
320    """Report and plot mutation percentages at each reference position.
321
322    Args:
323        cs_tags: Python sequence containing short- or long-form cs tag strings.
324        output_path: Path of the plot file to create. The format is inferred
325            from its extension. Use ``.pdf`` for an editable vector PDF with
326            embedded TrueType fonts, or ``.png`` for a raster image.
327        regions: Optional sequence of dictionaries describing regions to highlight
328            in the mutation plots. Each dictionary must contain ``name``
329            (the displayed label), ``start`` and ``end`` (1-based, inclusive
330            reference positions), and ``color`` (a Matplotlib-compatible color).
331            For example::
332
333                [
334                    {"name": "crRNA", "start": 80, "end": 99,
335                     "color": "lightblue"},
336                    {"name": "index", "start": 207, "end": 214,
337                     "color": "lightgreen"},
338                ]
339
340            Each region is drawn as a translucent vertical band behind the data
341            in all four mutation panels, with its name shown at the top of the
342            band. Overlapping regions remain visible through blended colors.
343
344    Returns:
345        One dictionary per 1-based relative reference position containing
346        coverage and total, insertion, deletion, and substitution percentages.
347    """
348    if not isinstance(output_path, Path):
349        raise TypeError("output_path must be a pathlib.Path")
350
351    profile = summarize_cs(cs_tags)
352    figure, _ = plot_mutation_percentages(profile, output_path, regions=regions)
353    plt.close(figure)
354    return profile

Report and plot mutation percentages at each reference position.

Args: cs_tags: Python sequence containing short- or long-form cs tag strings. output_path: Path of the plot file to create. The format is inferred from its extension. Use .pdf for an editable vector PDF with embedded TrueType fonts, or .png for a raster image. regions: Optional sequence of dictionaries describing regions to highlight in the mutation plots. Each dictionary must contain name (the displayed label), start and end (1-based, inclusive reference positions), and color (a Matplotlib-compatible color). For example::

        [
            {"name": "crRNA", "start": 80, "end": 99,
             "color": "lightblue"},
            {"name": "index", "start": 207, "end": 214,
             "color": "lightgreen"},
        ]

    Each region is drawn as a translucent vertical band behind the data
    in all four mutation panels, with its name shown at the top of the
    band. Overlapping regions remain visible through blended colors.

Returns: One dictionary per 1-based relative reference position containing coverage and total, insertion, deletion, and substitution percentages.