Skip to content

PAF I/O

This module provides classes and helpers for reading, writing, and reordering PAF (Pairwise mApping Format) alignment records.

PafAlignment — Alignment record collection

PafAlignment wraps a list of PafRecord objects and provides filtering, contig reordering, and sequence-length lookup utilities. It can be passed directly to DotPlotter — no SequenceIndex is required:

from dot_explorer.paf_io import PafAlignment
from dot_explorer.dotplot import DotPlotter

aln = PafAlignment.from_file("alignments.paf")
q_order, t_order = aln.reorder_contigs()

plotter = DotPlotter(aln)
plotter.plot(
    query_names=q_order,
    target_names=t_order,
    output_path="dotplot.png",
    scale_sequences=True,
)

PafRecord dataclass

A single PAF alignment record.

The twelve required PAF columns are represented as typed attributes. Optional SAM-like tags (e.g. tp:A:P, cg:Z:10M) are stored in :attr:tags. If a cg:Z: tag is present, CIGAR-derived alignment statistics are populated automatically.

Parameters:

Name Type Description Default
query_name str

Query sequence name (column 1).

required
query_len int

Query sequence length (column 2).

required
query_start int

Query start position, 0-based (column 3).

required
query_end int

Query end position, exclusive (column 4).

required
strand str

Relative strand: "+" or "-" (column 5).

required
target_name str

Target sequence name (column 6).

required
target_len int

Target sequence length (column 7).

required
target_start int

Target start position, 0-based (column 8).

required
target_end int

Target end position, exclusive (column 9).

required
residue_matches int

Number of residue matches (column 10).

required
alignment_block_len int

Number of bases in the alignment block (column 11).

required
mapping_quality int

Mapping quality (0–255; 255 = missing) (column 12).

required
tags dict[str, Any]

Optional SAM-like tags decoded as {tag_name: value}.

dict()
cigar str or None

CIGAR string from cg:Z: tag, or None if absent.

None
alignment_length int or None

Target-span alignment length derived from CIGAR, or None.

None
n_matches int or None

Count of exact-match bases (= ops) from CIGAR; falls back to residue_matches when only M ops are present.

None
n_mismatches int or None

Count of mismatch bases (X ops) from CIGAR, or None.

None
n_gaps int or None

Total number of gap bases (I + D bases) from CIGAR.

None
n_gap_bases int or None

Same as n_gaps (alias kept for clarity).

None
tag_types dict[str, str]

SAM tag type characters (e.g. {'tp': 'A'}) recorded at parse time so :meth:to_line can round-trip tags exactly.

dict()
Source code in dot_explorer/paf_io.py
@dataclass
class PafRecord:
    """A single PAF alignment record.

    The twelve required PAF columns are represented as typed attributes.
    Optional SAM-like tags (e.g. ``tp:A:P``, ``cg:Z:10M``) are stored in
    :attr:`tags`.  If a ``cg:Z:`` tag is present, CIGAR-derived alignment
    statistics are populated automatically.

    Parameters
    ----------
    query_name : str
        Query sequence name (column 1).
    query_len : int
        Query sequence length (column 2).
    query_start : int
        Query start position, 0-based (column 3).
    query_end : int
        Query end position, exclusive (column 4).
    strand : str
        Relative strand: ``"+"`` or ``"-"`` (column 5).
    target_name : str
        Target sequence name (column 6).
    target_len : int
        Target sequence length (column 7).
    target_start : int
        Target start position, 0-based (column 8).
    target_end : int
        Target end position, exclusive (column 9).
    residue_matches : int
        Number of residue matches (column 10).
    alignment_block_len : int
        Number of bases in the alignment block (column 11).
    mapping_quality : int
        Mapping quality (0–255; 255 = missing) (column 12).
    tags : dict[str, Any]
        Optional SAM-like tags decoded as ``{tag_name: value}``.
    cigar : str or None
        CIGAR string from ``cg:Z:`` tag, or ``None`` if absent.
    alignment_length : int or None
        Target-span alignment length derived from CIGAR, or ``None``.
    n_matches : int or None
        Count of exact-match bases (``=`` ops) from CIGAR; falls back to
        ``residue_matches`` when only ``M`` ops are present.
    n_mismatches : int or None
        Count of mismatch bases (``X`` ops) from CIGAR, or ``None``.
    n_gaps : int or None
        Total number of gap bases (``I`` + ``D`` bases) from CIGAR.
    n_gap_bases : int or None
        Same as ``n_gaps`` (alias kept for clarity).
    tag_types : dict[str, str]
        SAM tag type characters (e.g. ``{'tp': 'A'}``) recorded at parse
        time so :meth:`to_line` can round-trip tags exactly.
    """

    query_name: str
    query_len: int
    query_start: int
    query_end: int
    strand: str
    target_name: str
    target_len: int
    target_start: int
    target_end: int
    residue_matches: int
    alignment_block_len: int
    mapping_quality: int
    tags: dict[str, Any] = field(default_factory=dict)
    cigar: str | None = None
    alignment_length: int | None = None
    n_matches: int | None = None
    n_mismatches: int | None = None
    n_gaps: int | None = None
    n_gap_bases: int | None = None
    tag_types: dict[str, str] = field(default_factory=dict)

    @property
    def query_aligned_len(self) -> int:
        """Return the aligned length on the query sequence.

        Returns
        -------
        int
            ``query_end - query_start``.
        """
        return self.query_end - self.query_start

    @property
    def target_aligned_len(self) -> int:
        """Return the aligned length on the target sequence.

        Returns
        -------
        int
            ``target_end - target_start``.
        """
        return self.target_end - self.target_start

    @property
    def identity(self) -> float:
        """Return the fraction identity of this alignment in ``[0, 1]``.

        The most accurate available metric is used, in order of preference:

        1. ``1 - de`` from the ``de:f`` tag — minimap2's gap-compressed
           per-base divergence, emitted with base-level alignment (``-c``
           or ``--cs``).  A gap run counts as a single difference.
        2. Gap-compressed identity derived from the CIGAR:
           ``residue_matches / (aligned columns + gap openings)``.  Column
           10 is used as the match count because minimap2's default
           ``M``-style CIGAR does not distinguish mismatches.
        3. BLAST-style identity from the required PAF columns:
           ``residue_matches / alignment_block_len``.  For output without
           base-level alignment this is an approximation.

        Returns
        -------
        float
            Fraction identity, clamped to ``[0, 1]``.
        """
        de = self.tags.get('de')
        if isinstance(de, float):
            return max(0.0, min(1.0, 1.0 - de))
        if self.cigar is not None:
            ops = _parse_cigar(self.cigar)
            aligned_cols = ops.get('M', 0) + ops.get('=', 0) + ops.get('X', 0)
            denom = aligned_cols + (self.n_gaps or 0)
            if denom > 0:
                return min(1.0, self.residue_matches / denom)
        if self.alignment_block_len > 0:
            return min(1.0, self.residue_matches / self.alignment_block_len)
        return 1.0

    @classmethod
    def from_line(cls, line: str) -> 'PafRecord':
        """Parse a single PAF text line into a :class:`PafRecord`.

        Parameters
        ----------
        line : str
            A single PAF record line (tab-separated, trailing newline optional).

        Returns
        -------
        PafRecord
            The parsed record.

        Raises
        ------
        ValueError
            If the line has fewer than 12 tab-separated fields.
        """
        fields = line.rstrip('\n').split('\t')
        if len(fields) < 12:
            raise ValueError(
                f'PAF line has {len(fields)} fields; expected at least 12: {line!r}'
            )
        tags: dict[str, Any] = {}
        tag_types: dict[str, str] = {}
        cigar: str | None = None
        for tag_field in fields[12:]:
            parts = tag_field.split(':', 2)
            if len(parts) == 3:
                tag_name, tag_type, tag_value = parts
                if tag_type == 'i':
                    tags[tag_name] = int(tag_value)
                elif tag_type == 'f':
                    tags[tag_name] = float(tag_value)
                else:
                    tags[tag_name] = tag_value
                tag_types[tag_name] = tag_type
                if tag_name == 'cg' and tag_type == 'Z':
                    cigar = tag_value

        residue_matches = int(fields[9])
        stats: dict[str, int] = {}
        if cigar is not None:
            stats = _cigar_stats(cigar, residue_matches)

        return cls(
            query_name=fields[0],
            query_len=int(fields[1]),
            query_start=int(fields[2]),
            query_end=int(fields[3]),
            strand=fields[4],
            target_name=fields[5],
            target_len=int(fields[6]),
            target_start=int(fields[7]),
            target_end=int(fields[8]),
            residue_matches=residue_matches,
            alignment_block_len=int(fields[10]),
            mapping_quality=int(fields[11]),
            tags=tags,
            cigar=cigar,
            alignment_length=stats.get('alignment_length'),
            n_matches=stats.get('n_matches'),
            n_mismatches=stats.get('n_mismatches'),
            n_gaps=stats.get('n_gaps'),
            n_gap_bases=stats.get('n_gap_bases'),
            tag_types=tag_types,
        )

    def to_line(self) -> str:
        """Serialise this record back to a PAF-format string (no trailing newline).

        Returns
        -------
        str
            Tab-separated PAF line with the 12 required columns followed by
            any optional tags in insertion order.  Tag type characters come
            from :attr:`tag_types` when recorded at parse time, otherwise
            they are inferred from the Python value (``int`` -> ``i``,
            ``float`` -> ``f``, else ``Z``).
        """
        parts = [
            str(v)
            for v in [
                self.query_name,
                self.query_len,
                self.query_start,
                self.query_end,
                self.strand,
                self.target_name,
                self.target_len,
                self.target_start,
                self.target_end,
                self.residue_matches,
                self.alignment_block_len,
                self.mapping_quality,
            ]
        ]
        for name, value in self.tags.items():
            tag_type = self.tag_types.get(name)
            if tag_type is None:
                if isinstance(value, int):
                    tag_type = 'i'
                elif isinstance(value, float):
                    tag_type = 'f'
                else:
                    tag_type = 'Z'
            text = f'{value:g}' if isinstance(value, float) else str(value)
            parts.append(f'{name}:{tag_type}:{text}')
        return '\t'.join(parts)

Attributes

query_aligned_len property

Return the aligned length on the query sequence.

Returns:

Type Description
int

query_end - query_start.

target_aligned_len property

Return the aligned length on the target sequence.

Returns:

Type Description
int

target_end - target_start.

identity property

Return the fraction identity of this alignment in [0, 1].

The most accurate available metric is used, in order of preference:

  1. 1 - de from the de:f tag — minimap2's gap-compressed per-base divergence, emitted with base-level alignment (-c or --cs). A gap run counts as a single difference.
  2. Gap-compressed identity derived from the CIGAR: residue_matches / (aligned columns + gap openings). Column 10 is used as the match count because minimap2's default M-style CIGAR does not distinguish mismatches.
  3. BLAST-style identity from the required PAF columns: residue_matches / alignment_block_len. For output without base-level alignment this is an approximation.

Returns:

Type Description
float

Fraction identity, clamped to [0, 1].

Methods:

from_line(line) classmethod

Parse a single PAF text line into a :class:PafRecord.

Parameters:

Name Type Description Default
line str

A single PAF record line (tab-separated, trailing newline optional).

required

Returns:

Type Description
PafRecord

The parsed record.

Raises:

Type Description
ValueError

If the line has fewer than 12 tab-separated fields.

Source code in dot_explorer/paf_io.py
@classmethod
def from_line(cls, line: str) -> 'PafRecord':
    """Parse a single PAF text line into a :class:`PafRecord`.

    Parameters
    ----------
    line : str
        A single PAF record line (tab-separated, trailing newline optional).

    Returns
    -------
    PafRecord
        The parsed record.

    Raises
    ------
    ValueError
        If the line has fewer than 12 tab-separated fields.
    """
    fields = line.rstrip('\n').split('\t')
    if len(fields) < 12:
        raise ValueError(
            f'PAF line has {len(fields)} fields; expected at least 12: {line!r}'
        )
    tags: dict[str, Any] = {}
    tag_types: dict[str, str] = {}
    cigar: str | None = None
    for tag_field in fields[12:]:
        parts = tag_field.split(':', 2)
        if len(parts) == 3:
            tag_name, tag_type, tag_value = parts
            if tag_type == 'i':
                tags[tag_name] = int(tag_value)
            elif tag_type == 'f':
                tags[tag_name] = float(tag_value)
            else:
                tags[tag_name] = tag_value
            tag_types[tag_name] = tag_type
            if tag_name == 'cg' and tag_type == 'Z':
                cigar = tag_value

    residue_matches = int(fields[9])
    stats: dict[str, int] = {}
    if cigar is not None:
        stats = _cigar_stats(cigar, residue_matches)

    return cls(
        query_name=fields[0],
        query_len=int(fields[1]),
        query_start=int(fields[2]),
        query_end=int(fields[3]),
        strand=fields[4],
        target_name=fields[5],
        target_len=int(fields[6]),
        target_start=int(fields[7]),
        target_end=int(fields[8]),
        residue_matches=residue_matches,
        alignment_block_len=int(fields[10]),
        mapping_quality=int(fields[11]),
        tags=tags,
        cigar=cigar,
        alignment_length=stats.get('alignment_length'),
        n_matches=stats.get('n_matches'),
        n_mismatches=stats.get('n_mismatches'),
        n_gaps=stats.get('n_gaps'),
        n_gap_bases=stats.get('n_gap_bases'),
        tag_types=tag_types,
    )

to_line()

Serialise this record back to a PAF-format string (no trailing newline).

Returns:

Type Description
str

Tab-separated PAF line with the 12 required columns followed by any optional tags in insertion order. Tag type characters come from :attr:tag_types when recorded at parse time, otherwise they are inferred from the Python value (int -> i, float -> f, else Z).

Source code in dot_explorer/paf_io.py
def to_line(self) -> str:
    """Serialise this record back to a PAF-format string (no trailing newline).

    Returns
    -------
    str
        Tab-separated PAF line with the 12 required columns followed by
        any optional tags in insertion order.  Tag type characters come
        from :attr:`tag_types` when recorded at parse time, otherwise
        they are inferred from the Python value (``int`` -> ``i``,
        ``float`` -> ``f``, else ``Z``).
    """
    parts = [
        str(v)
        for v in [
            self.query_name,
            self.query_len,
            self.query_start,
            self.query_end,
            self.strand,
            self.target_name,
            self.target_len,
            self.target_start,
            self.target_end,
            self.residue_matches,
            self.alignment_block_len,
            self.mapping_quality,
        ]
    ]
    for name, value in self.tags.items():
        tag_type = self.tag_types.get(name)
        if tag_type is None:
            if isinstance(value, int):
                tag_type = 'i'
            elif isinstance(value, float):
                tag_type = 'f'
            else:
                tag_type = 'Z'
        text = f'{value:g}' if isinstance(value, float) else str(value)
        parts.append(f'{name}:{tag_type}:{text}')
    return '\t'.join(parts)

PafAlignment

A collection of PAF alignment records with contig-ordering utilities.

Can be constructed from a file path or an iterable of :class:PafRecord objects. Provides :meth:reorder_contigs to sort query and target sequence names so that a subsequent dotplot shows maximum collinearity.

Parameters:

Name Type Description Default
records list of PafRecord

The alignment records.

required

Examples:

Load from a file and reorder contigs:

>>> aln = PafAlignment.from_file("alignments.paf")
>>> q_order, t_order = aln.reorder_contigs(aln.query_names, aln.target_names)
Source code in dot_explorer/paf_io.py
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
class PafAlignment:
    """A collection of PAF alignment records with contig-ordering utilities.

    Can be constructed from a file path or an iterable of :class:`PafRecord`
    objects.  Provides :meth:`reorder_contigs` to sort query and target
    sequence names so that a subsequent dotplot shows maximum collinearity.

    Parameters
    ----------
    records : list of PafRecord
        The alignment records.

    Examples
    --------
    Load from a file and reorder contigs:

    >>> aln = PafAlignment.from_file("alignments.paf")
    >>> q_order, t_order = aln.reorder_contigs(aln.query_names, aln.target_names)
    """

    def __init__(self, records: list[PafRecord]) -> None:
        self.records: list[PafRecord] = records
        # Custom group assignments.  None means use the default (query_names
        # → 'a', target_names → 'b') which is computed lazily from records.
        self._groups: dict[str, list[str]] | None = None
        # Query contigs detected as reverse-oriented by the most recent
        # :meth:`reorder_contigs` call (empty until then).
        self._reversed_query: set[str] = set()

    # ------------------------------------------------------------------
    # Constructors
    # ------------------------------------------------------------------

    @classmethod
    def from_file(cls, path: str | Path) -> 'PafAlignment':
        """Load records from a PAF file.

        Parameters
        ----------
        path : str or Path
            Path to the PAF file.

        Returns
        -------
        PafAlignment
            New instance with all records loaded.
        """
        return cls(list(parse_paf_file(path)))

    @classmethod
    def from_records(cls, records: Iterable[PafRecord]) -> 'PafAlignment':
        """Construct from an iterable of :class:`PafRecord` objects.

        Parameters
        ----------
        records : iterable of PafRecord
            Source records.

        Returns
        -------
        PafAlignment
            New instance.
        """
        return cls(list(records))

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def query_names(self) -> list[str]:
        """Return a deduplicated list of query sequence names (insertion order).

        Returns
        -------
        list[str]
            Unique query names in the order first seen.
        """
        seen: dict[str, None] = {}
        for rec in self.records:
            seen[rec.query_name] = None
        return list(seen)

    @property
    def target_names(self) -> list[str]:
        """Return a deduplicated list of target sequence names (insertion order).

        Returns
        -------
        list[str]
            Unique target names in the order first seen.
        """
        seen: dict[str, None] = {}
        for rec in self.records:
            seen[rec.target_name] = None
        return list(seen)

    def sequence_names(self) -> list[str]:
        """Return a deduplicated list of all query and target sequence names.

        The list contains each name at most once, in the order it was first
        encountered (queries before targets within each record).  This method
        makes :class:`PafAlignment` compatible with :class:`~dot_explorer.dotplot.DotPlotter`.

        Returns
        -------
        list[str]
            All unique sequence names across query and target fields.
        """
        seen: dict[str, None] = {}
        for rec in self.records:
            seen[rec.query_name] = None
            seen[rec.target_name] = None
        return list(seen)

    def get_sequence_length(self, name: str) -> int:
        """Return the length of a sequence by name as stored in PAF records.

        Looks up *name* in the ``query_name`` and ``target_name`` fields of
        every record and returns the corresponding ``query_len`` or
        ``target_len``.  This method makes :class:`PafAlignment` compatible
        with :class:`~dot_explorer.dotplot.DotPlotter`.

        Parameters
        ----------
        name : str
            Sequence name to look up.

        Returns
        -------
        int
            Length of the sequence.

        Raises
        ------
        KeyError
            If *name* is not found in any record.
        """
        for rec in self.records:
            if rec.query_name == name:
                return rec.query_len
            if rec.target_name == name:
                return rec.target_len
        raise KeyError(f'Sequence {name!r} not found in PAF records.')

    def __len__(self) -> int:
        """Return the number of records.

        Returns
        -------
        int
            Record count.
        """
        return len(self.records)

    def __repr__(self) -> str:
        """Return a concise string representation.

        Returns
        -------
        str
            ``PafAlignment(records=<n>, queries=<q>, targets=<t>)``.
        """
        return (
            f'PafAlignment(records={len(self.records)}, '
            f'queries={len(self.query_names)}, '
            f'targets={len(self.target_names)})'
        )

    # ------------------------------------------------------------------
    # Filtering
    # ------------------------------------------------------------------

    def filter_by_query(self, names: Iterable[str]) -> 'PafAlignment':
        """Return a new :class:`PafAlignment` containing only the given query names.

        Parameters
        ----------
        names : iterable of str
            Query names to keep.

        Returns
        -------
        PafAlignment
            Filtered alignment.
        """
        keep = set(names)
        return PafAlignment([r for r in self.records if r.query_name in keep])

    def filter_by_target(self, names: Iterable[str]) -> 'PafAlignment':
        """Return a new :class:`PafAlignment` containing only the given target names.

        Parameters
        ----------
        names : iterable of str
            Target names to keep.

        Returns
        -------
        PafAlignment
            Filtered alignment.
        """
        keep = set(names)
        return PafAlignment([r for r in self.records if r.target_name in keep])

    def filter_by_min_length(self, min_length: int) -> 'PafAlignment':
        """Return a new :class:`PafAlignment` keeping only records of sufficient length.

        Filters on the query aligned length (``query_end - query_start``), which
        equals the alignment block span for both merged k-mer runs and PAF
        alignments imported from a file.

        Parameters
        ----------
        min_length : int
            Minimum alignment length (inclusive).  Records with a query aligned
            length strictly less than ``min_length`` are discarded.

        Returns
        -------
        PafAlignment
            Filtered alignment containing only records with
            ``query_aligned_len >= min_length``.
        """
        return PafAlignment(
            [r for r in self.records if r.query_aligned_len >= min_length]
        )

    # ------------------------------------------------------------------
    # Group management
    # ------------------------------------------------------------------

    @property
    def groups(self) -> dict[str, list[str]]:
        """Return the current group assignments.

        If groups have not been set explicitly via :meth:`set_groups` or
        :meth:`rename_group`, returns the default: all query sequence names
        in group ``'a'`` and all target sequence names in group ``'b'``.

        Returns
        -------
        dict[str, list[str]]
            Mapping of group label → list of sequence names.
        """
        if self._groups is not None:
            return dict(self._groups)
        return {'a': self.query_names, 'b': self.target_names}

    def set_groups(self, groups: dict[str, list[str]]) -> None:
        """Set custom group assignments for sequence names.

        Parameters
        ----------
        groups : dict[str, list[str]]
            Mapping of group label → list of sequence names belonging to
            that group.

        Warns
        -----
        Logs a warning for every sequence name that appears in more than one
        group.
        """
        seen: dict[str, str] = {}
        for group, names in groups.items():
            for name in names:
                if name in seen:
                    _log.warning(
                        'PafAlignment.set_groups: sequence %r is assigned to '
                        'both group %r and group %r',
                        name,
                        seen[name],
                        group,
                    )
                else:
                    seen[name] = group
        self._groups = {g: list(ns) for g, ns in groups.items()}

    def rename_group(self, old_name: str, new_name: str) -> None:
        """Rename a group label.

        If custom groups have not been set yet, the default assignment
        (``'a'`` → query names, ``'b'`` → target names) is materialised
        first.

        Parameters
        ----------
        old_name : str
            Current group label.
        new_name : str
            New group label.

        Raises
        ------
        KeyError
            If *old_name* is not a known group.
        ValueError
            If *new_name* already exists as a different group label.
        """
        current = self._groups if self._groups is not None else self.groups
        if old_name not in current:
            raise KeyError(f'Group {old_name!r} not found.')
        if new_name in current and new_name != old_name:
            raise ValueError(f'Group {new_name!r} already exists.')
        self._groups = {
            (new_name if k == old_name else k): v for k, v in current.items()
        }
        _log.info('PafAlignment: renamed group %r%r', old_name, new_name)

    # ------------------------------------------------------------------
    # Contig reordering
    # ------------------------------------------------------------------

    def reorder_contigs(
        self,
        query_names: list[str] | None = None,
        target_names: list[str] | None = None,
        query_group: str | None = None,
        target_group: str | None = None,
    ) -> tuple[list[str], list[str]]:
        """Sort query and target contigs to maximise collinearity in the dotplot.

        Uses the gravity-centre algorithm: each contig is assigned a gravity
        equal to the weighted mean position of its alignment blocks on the
        opposing axis.  Contigs are then sorted by ascending gravity.

        Parameters
        ----------
        query_names : list[str] or None, optional
            Query contigs to reorder.  Ignored when *query_group* is given.
            Defaults to :attr:`query_names`.
        target_names : list[str] or None, optional
            Target contigs to reorder.  Ignored when *target_group* is given.
            Defaults to :attr:`target_names`.
        query_group : str or None, optional
            Group label whose members are used as query contigs.  When
            provided, the corresponding entry in :attr:`groups` is used and
            *query_names* is ignored.
        target_group : str or None, optional
            Group label whose members are used as target contigs.  When
            provided, the corresponding entry in :attr:`groups` is used and
            *target_names* is ignored.

        Returns
        -------
        tuple[list[str], list[str]]
            ``(sorted_query_names, sorted_target_names)``.  The set of query
            contigs detected as reverse-oriented is stored on
            :attr:`reversed_contigs` (not returned, for backward compatibility).

        Raises
        ------
        KeyError
            If a supplied group label is not present in :attr:`groups`.
        """
        current_groups = self.groups
        if query_group is not None:
            if query_group not in current_groups:
                raise KeyError(f'Group {query_group!r} not found.')
            q = current_groups[query_group]
        else:
            q = query_names if query_names is not None else self.query_names

        if target_group is not None:
            if target_group not in current_groups:
                raise KeyError(f'Group {target_group!r} not found.')
            t = current_groups[target_group]
        else:
            t = target_names if target_names is not None else self.target_names

        sorted_q, sorted_t, reversed_q = compute_gravity_contigs(self.records, q, t)
        self._reversed_query = reversed_q
        return sorted_q, sorted_t

    @property
    def reversed_contigs(self) -> set[str]:
        """Query contigs detected as reverse-oriented by :meth:`reorder_contigs`.

        Empty until :meth:`reorder_contigs` has been called.  These contigs
        align in reverse orientation against their best-matching target and can
        be passed to :meth:`~dot_explorer.dotplot.DotPlotter.plot` via
        ``reverse_contigs=`` to be rendered flipped along the main diagonal.

        Returns
        -------
        set[str]
            Names of reverse-oriented query contigs.
        """
        return set(self._reversed_query)

Attributes

query_names property

Return a deduplicated list of query sequence names (insertion order).

Returns:

Type Description
list[str]

Unique query names in the order first seen.

target_names property

Return a deduplicated list of target sequence names (insertion order).

Returns:

Type Description
list[str]

Unique target names in the order first seen.

groups property

Return the current group assignments.

If groups have not been set explicitly via :meth:set_groups or :meth:rename_group, returns the default: all query sequence names in group 'a' and all target sequence names in group 'b'.

Returns:

Type Description
dict[str, list[str]]

Mapping of group label → list of sequence names.

reversed_contigs property

Query contigs detected as reverse-oriented by :meth:reorder_contigs.

Empty until :meth:reorder_contigs has been called. These contigs align in reverse orientation against their best-matching target and can be passed to :meth:~dot_explorer.dotplot.DotPlotter.plot via reverse_contigs= to be rendered flipped along the main diagonal.

Returns:

Type Description
set[str]

Names of reverse-oriented query contigs.

Methods:

from_file(path) classmethod

Load records from a PAF file.

Parameters:

Name Type Description Default
path str or Path

Path to the PAF file.

required

Returns:

Type Description
PafAlignment

New instance with all records loaded.

Source code in dot_explorer/paf_io.py
@classmethod
def from_file(cls, path: str | Path) -> 'PafAlignment':
    """Load records from a PAF file.

    Parameters
    ----------
    path : str or Path
        Path to the PAF file.

    Returns
    -------
    PafAlignment
        New instance with all records loaded.
    """
    return cls(list(parse_paf_file(path)))

from_records(records) classmethod

Construct from an iterable of :class:PafRecord objects.

Parameters:

Name Type Description Default
records iterable of PafRecord

Source records.

required

Returns:

Type Description
PafAlignment

New instance.

Source code in dot_explorer/paf_io.py
@classmethod
def from_records(cls, records: Iterable[PafRecord]) -> 'PafAlignment':
    """Construct from an iterable of :class:`PafRecord` objects.

    Parameters
    ----------
    records : iterable of PafRecord
        Source records.

    Returns
    -------
    PafAlignment
        New instance.
    """
    return cls(list(records))

sequence_names()

Return a deduplicated list of all query and target sequence names.

The list contains each name at most once, in the order it was first encountered (queries before targets within each record). This method makes :class:PafAlignment compatible with :class:~dot_explorer.dotplot.DotPlotter.

Returns:

Type Description
list[str]

All unique sequence names across query and target fields.

Source code in dot_explorer/paf_io.py
def sequence_names(self) -> list[str]:
    """Return a deduplicated list of all query and target sequence names.

    The list contains each name at most once, in the order it was first
    encountered (queries before targets within each record).  This method
    makes :class:`PafAlignment` compatible with :class:`~dot_explorer.dotplot.DotPlotter`.

    Returns
    -------
    list[str]
        All unique sequence names across query and target fields.
    """
    seen: dict[str, None] = {}
    for rec in self.records:
        seen[rec.query_name] = None
        seen[rec.target_name] = None
    return list(seen)

get_sequence_length(name)

Return the length of a sequence by name as stored in PAF records.

Looks up name in the query_name and target_name fields of every record and returns the corresponding query_len or target_len. This method makes :class:PafAlignment compatible with :class:~dot_explorer.dotplot.DotPlotter.

Parameters:

Name Type Description Default
name str

Sequence name to look up.

required

Returns:

Type Description
int

Length of the sequence.

Raises:

Type Description
KeyError

If name is not found in any record.

Source code in dot_explorer/paf_io.py
def get_sequence_length(self, name: str) -> int:
    """Return the length of a sequence by name as stored in PAF records.

    Looks up *name* in the ``query_name`` and ``target_name`` fields of
    every record and returns the corresponding ``query_len`` or
    ``target_len``.  This method makes :class:`PafAlignment` compatible
    with :class:`~dot_explorer.dotplot.DotPlotter`.

    Parameters
    ----------
    name : str
        Sequence name to look up.

    Returns
    -------
    int
        Length of the sequence.

    Raises
    ------
    KeyError
        If *name* is not found in any record.
    """
    for rec in self.records:
        if rec.query_name == name:
            return rec.query_len
        if rec.target_name == name:
            return rec.target_len
    raise KeyError(f'Sequence {name!r} not found in PAF records.')

__len__()

Return the number of records.

Returns:

Type Description
int

Record count.

Source code in dot_explorer/paf_io.py
def __len__(self) -> int:
    """Return the number of records.

    Returns
    -------
    int
        Record count.
    """
    return len(self.records)

__repr__()

Return a concise string representation.

Returns:

Type Description
str

PafAlignment(records=<n>, queries=<q>, targets=<t>).

Source code in dot_explorer/paf_io.py
def __repr__(self) -> str:
    """Return a concise string representation.

    Returns
    -------
    str
        ``PafAlignment(records=<n>, queries=<q>, targets=<t>)``.
    """
    return (
        f'PafAlignment(records={len(self.records)}, '
        f'queries={len(self.query_names)}, '
        f'targets={len(self.target_names)})'
    )

filter_by_query(names)

Return a new :class:PafAlignment containing only the given query names.

Parameters:

Name Type Description Default
names iterable of str

Query names to keep.

required

Returns:

Type Description
PafAlignment

Filtered alignment.

Source code in dot_explorer/paf_io.py
def filter_by_query(self, names: Iterable[str]) -> 'PafAlignment':
    """Return a new :class:`PafAlignment` containing only the given query names.

    Parameters
    ----------
    names : iterable of str
        Query names to keep.

    Returns
    -------
    PafAlignment
        Filtered alignment.
    """
    keep = set(names)
    return PafAlignment([r for r in self.records if r.query_name in keep])

filter_by_target(names)

Return a new :class:PafAlignment containing only the given target names.

Parameters:

Name Type Description Default
names iterable of str

Target names to keep.

required

Returns:

Type Description
PafAlignment

Filtered alignment.

Source code in dot_explorer/paf_io.py
def filter_by_target(self, names: Iterable[str]) -> 'PafAlignment':
    """Return a new :class:`PafAlignment` containing only the given target names.

    Parameters
    ----------
    names : iterable of str
        Target names to keep.

    Returns
    -------
    PafAlignment
        Filtered alignment.
    """
    keep = set(names)
    return PafAlignment([r for r in self.records if r.target_name in keep])

filter_by_min_length(min_length)

Return a new :class:PafAlignment keeping only records of sufficient length.

Filters on the query aligned length (query_end - query_start), which equals the alignment block span for both merged k-mer runs and PAF alignments imported from a file.

Parameters:

Name Type Description Default
min_length int

Minimum alignment length (inclusive). Records with a query aligned length strictly less than min_length are discarded.

required

Returns:

Type Description
PafAlignment

Filtered alignment containing only records with query_aligned_len >= min_length.

Source code in dot_explorer/paf_io.py
def filter_by_min_length(self, min_length: int) -> 'PafAlignment':
    """Return a new :class:`PafAlignment` keeping only records of sufficient length.

    Filters on the query aligned length (``query_end - query_start``), which
    equals the alignment block span for both merged k-mer runs and PAF
    alignments imported from a file.

    Parameters
    ----------
    min_length : int
        Minimum alignment length (inclusive).  Records with a query aligned
        length strictly less than ``min_length`` are discarded.

    Returns
    -------
    PafAlignment
        Filtered alignment containing only records with
        ``query_aligned_len >= min_length``.
    """
    return PafAlignment(
        [r for r in self.records if r.query_aligned_len >= min_length]
    )

set_groups(groups)

Set custom group assignments for sequence names.

Parameters:

Name Type Description Default
groups dict[str, list[str]]

Mapping of group label → list of sequence names belonging to that group.

required

Warns:

Type Description
Logs a warning for every sequence name that appears in more than one
group.
Source code in dot_explorer/paf_io.py
def set_groups(self, groups: dict[str, list[str]]) -> None:
    """Set custom group assignments for sequence names.

    Parameters
    ----------
    groups : dict[str, list[str]]
        Mapping of group label → list of sequence names belonging to
        that group.

    Warns
    -----
    Logs a warning for every sequence name that appears in more than one
    group.
    """
    seen: dict[str, str] = {}
    for group, names in groups.items():
        for name in names:
            if name in seen:
                _log.warning(
                    'PafAlignment.set_groups: sequence %r is assigned to '
                    'both group %r and group %r',
                    name,
                    seen[name],
                    group,
                )
            else:
                seen[name] = group
    self._groups = {g: list(ns) for g, ns in groups.items()}

rename_group(old_name, new_name)

Rename a group label.

If custom groups have not been set yet, the default assignment ('a' → query names, 'b' → target names) is materialised first.

Parameters:

Name Type Description Default
old_name str

Current group label.

required
new_name str

New group label.

required

Raises:

Type Description
KeyError

If old_name is not a known group.

ValueError

If new_name already exists as a different group label.

Source code in dot_explorer/paf_io.py
def rename_group(self, old_name: str, new_name: str) -> None:
    """Rename a group label.

    If custom groups have not been set yet, the default assignment
    (``'a'`` → query names, ``'b'`` → target names) is materialised
    first.

    Parameters
    ----------
    old_name : str
        Current group label.
    new_name : str
        New group label.

    Raises
    ------
    KeyError
        If *old_name* is not a known group.
    ValueError
        If *new_name* already exists as a different group label.
    """
    current = self._groups if self._groups is not None else self.groups
    if old_name not in current:
        raise KeyError(f'Group {old_name!r} not found.')
    if new_name in current and new_name != old_name:
        raise ValueError(f'Group {new_name!r} already exists.')
    self._groups = {
        (new_name if k == old_name else k): v for k, v in current.items()
    }
    _log.info('PafAlignment: renamed group %r%r', old_name, new_name)

reorder_contigs(query_names=None, target_names=None, query_group=None, target_group=None)

Sort query and target contigs to maximise collinearity in the dotplot.

Uses the gravity-centre algorithm: each contig is assigned a gravity equal to the weighted mean position of its alignment blocks on the opposing axis. Contigs are then sorted by ascending gravity.

Parameters:

Name Type Description Default
query_names list[str] or None

Query contigs to reorder. Ignored when query_group is given. Defaults to :attr:query_names.

None
target_names list[str] or None

Target contigs to reorder. Ignored when target_group is given. Defaults to :attr:target_names.

None
query_group str or None

Group label whose members are used as query contigs. When provided, the corresponding entry in :attr:groups is used and query_names is ignored.

None
target_group str or None

Group label whose members are used as target contigs. When provided, the corresponding entry in :attr:groups is used and target_names is ignored.

None

Returns:

Type Description
tuple[list[str], list[str]]

(sorted_query_names, sorted_target_names). The set of query contigs detected as reverse-oriented is stored on :attr:reversed_contigs (not returned, for backward compatibility).

Raises:

Type Description
KeyError

If a supplied group label is not present in :attr:groups.

Source code in dot_explorer/paf_io.py
def reorder_contigs(
    self,
    query_names: list[str] | None = None,
    target_names: list[str] | None = None,
    query_group: str | None = None,
    target_group: str | None = None,
) -> tuple[list[str], list[str]]:
    """Sort query and target contigs to maximise collinearity in the dotplot.

    Uses the gravity-centre algorithm: each contig is assigned a gravity
    equal to the weighted mean position of its alignment blocks on the
    opposing axis.  Contigs are then sorted by ascending gravity.

    Parameters
    ----------
    query_names : list[str] or None, optional
        Query contigs to reorder.  Ignored when *query_group* is given.
        Defaults to :attr:`query_names`.
    target_names : list[str] or None, optional
        Target contigs to reorder.  Ignored when *target_group* is given.
        Defaults to :attr:`target_names`.
    query_group : str or None, optional
        Group label whose members are used as query contigs.  When
        provided, the corresponding entry in :attr:`groups` is used and
        *query_names* is ignored.
    target_group : str or None, optional
        Group label whose members are used as target contigs.  When
        provided, the corresponding entry in :attr:`groups` is used and
        *target_names* is ignored.

    Returns
    -------
    tuple[list[str], list[str]]
        ``(sorted_query_names, sorted_target_names)``.  The set of query
        contigs detected as reverse-oriented is stored on
        :attr:`reversed_contigs` (not returned, for backward compatibility).

    Raises
    ------
    KeyError
        If a supplied group label is not present in :attr:`groups`.
    """
    current_groups = self.groups
    if query_group is not None:
        if query_group not in current_groups:
            raise KeyError(f'Group {query_group!r} not found.')
        q = current_groups[query_group]
    else:
        q = query_names if query_names is not None else self.query_names

    if target_group is not None:
        if target_group not in current_groups:
            raise KeyError(f'Group {target_group!r} not found.')
        t = current_groups[target_group]
    else:
        t = target_names if target_names is not None else self.target_names

    sorted_q, sorted_t, reversed_q = compute_gravity_contigs(self.records, q, t)
    self._reversed_query = reversed_q
    return sorted_q, sorted_t

Functions

parse_paf_file(path)

Yield :class:PafRecord objects from a PAF file.

Lines beginning with # are treated as comments and skipped. Empty lines are also skipped.

Parameters:

Name Type Description Default
path str or Path

Path to the PAF file.

required

Yields:

Type Description
PafRecord

One record per non-comment, non-empty line.

Raises:

Type Description
FileNotFoundError

If path does not exist.

ValueError

If a line cannot be parsed as a PAF record.

Source code in dot_explorer/paf_io.py
def parse_paf_file(path: str | Path) -> Generator[PafRecord, None, None]:
    """Yield :class:`PafRecord` objects from a PAF file.

    Lines beginning with ``#`` are treated as comments and skipped.  Empty
    lines are also skipped.

    Parameters
    ----------
    path : str or Path
        Path to the PAF file.

    Yields
    ------
    PafRecord
        One record per non-comment, non-empty line.

    Raises
    ------
    FileNotFoundError
        If ``path`` does not exist.
    ValueError
        If a line cannot be parsed as a PAF record.
    """
    path = Path(path)
    with path.open('r', encoding='utf-8') as fh:
        for line in fh:
            line = line.rstrip('\n')
            if not line or line.startswith('#'):
                continue
            yield PafRecord.from_line(line)

compute_gravity_contigs(records, query_names, target_names, sort_targets=True)

Return query/target contigs sorted by gravity centre, plus reversed set.

Implements the d-genies gravity algorithm: each contig is assigned to its single best-matching chromosome (argmax of summed squared match weights, (1 + euclidean_length) ** 2) and positioned by the squared-weighted mean of its match mid-points on the concatenated opposing axis, using only the matches to that best chromosome. Contigs are then sorted by ascending position; contigs with no alignments are placed last, by descending length.

When sort_targets is True (default) the targets are ordered first (against the queries in their input order) and the queries are then ordered against the freshly sorted target axis, so contigs group according to the displayed target arrangement. When False the target order is treated as fixed (returned unchanged) and only the queries are reordered against it — use this to reorder one assembly against another that must not move.

Parameters:

Name Type Description Default
records iterable of PafRecord

Alignment records to use for computing gravity centres.

required
query_names list[str]

The query contig names to reorder.

required
target_names list[str]

The target contig names to reorder (or to keep fixed when sort_targets is False).

required
sort_targets bool

Whether to reorder the targets too. Default is True.

True

Returns:

Type Description
tuple[list[str], list[str], set[str]]

(sorted_query_names, sorted_target_names, reversed_query_names) where reversed_query_names are the query contigs detected as reverse-oriented against their best-matching chromosome (see :func:compute_reversed_contigs). sorted_target_names equals target_names unchanged when sort_targets is False.

Source code in dot_explorer/paf_io.py
def compute_gravity_contigs(
    records: Iterable[PafRecord],
    query_names: list[str],
    target_names: list[str],
    sort_targets: bool = True,
) -> tuple[list[str], list[str], set[str]]:
    """Return query/target contigs sorted by gravity centre, plus reversed set.

    Implements the d-genies gravity algorithm: each contig is assigned to its
    single best-matching chromosome (argmax of summed squared match weights,
    ``(1 + euclidean_length) ** 2``) and positioned by the squared-weighted mean
    of its match mid-points on the concatenated opposing axis, using only the
    matches to that best chromosome.  Contigs are then sorted by ascending
    position; contigs with no alignments are placed last, by descending length.

    When *sort_targets* is ``True`` (default) the targets are ordered first
    (against the queries in their input order) and the queries are then ordered
    against the freshly sorted target axis, so contigs group according to the
    displayed target arrangement.  When ``False`` the target order is treated as
    fixed (returned unchanged) and only the queries are reordered against it —
    use this to reorder one assembly against another that must not move.

    Parameters
    ----------
    records : iterable of PafRecord
        Alignment records to use for computing gravity centres.
    query_names : list[str]
        The query contig names to reorder.
    target_names : list[str]
        The target contig names to reorder (or to keep fixed when
        *sort_targets* is ``False``).
    sort_targets : bool, optional
        Whether to reorder the targets too.  Default is ``True``.

    Returns
    -------
    tuple[list[str], list[str], set[str]]
        ``(sorted_query_names, sorted_target_names, reversed_query_names)`` where
        *reversed_query_names* are the query contigs detected as reverse-oriented
        against their best-matching chromosome (see
        :func:`compute_reversed_contigs`).  *sorted_target_names* equals
        *target_names* unchanged when *sort_targets* is ``False``.
    """
    query_set = set(query_names)
    target_set = set(target_names)

    # Bucket records by (query, target) and build sequence-length maps.
    matches: dict[tuple[str, str], list[PafRecord]] = {}
    len_map: dict[str, int] = {}
    for rec in records:
        len_map[rec.query_name] = rec.query_len
        len_map[rec.target_name] = rec.target_len
        if rec.query_name not in query_set or rec.target_name not in target_set:
            continue
        matches.setdefault((rec.query_name, rec.target_name), []).append(rec)

    # Order targets first (others = queries in input order) unless they are held
    # fixed, then order queries against the resulting target axis.  This
    # asymmetry is intentional and matches the Rust implementation.
    if sort_targets:
        sorted_t, _ = _gravity_order(
            target_names, query_names, matches, len_map, self_is_query=False
        )
    else:
        sorted_t = list(target_names)
    sorted_q, best_other_q = _gravity_order(
        query_names, sorted_t, matches, len_map, self_is_query=True
    )

    reversed_q = compute_reversed_contigs(query_names, matches, len_map, best_other_q)
    return sorted_q, sorted_t, reversed_q

compute_reversed_contigs(query_names, matches, len_map, best_other)

Return query contigs that are reverse-oriented against their best target.

Ports d-genies' is_contig_well_oriented: for each query contig, take its matches on its best-matching chromosome, keep only "big" matches (euclidean length greater than 10% of the longest match and at least 1% of min(contig_len, chrom_len)), sort them by query mid-point, and check whether the target mid-point trends upward. A contig is reverse-oriented when the mean of the consecutive-pair direction signs is <= -0.1 (for a single big match, when that match is on the - strand). Contigs with no big matches are treated as forward (well oriented).

Parameters:

Name Type Description Default
query_names list[str]

Query contig names to test.

required
matches dict[tuple[str, str], list[PafRecord]]

Records keyed by (query_name, target_name).

required
len_map dict[str, int]

Sequence lengths for every name.

required
best_other dict[str, str | None]

Mapping of each query contig to its best-matching target (or None).

required

Returns:

Type Description
set[str]

Names of query contigs detected as reverse-oriented.

Source code in dot_explorer/paf_io.py
def compute_reversed_contigs(
    query_names: list[str],
    matches: dict[tuple[str, str], list[PafRecord]],
    len_map: dict[str, int],
    best_other: dict[str, str | None],
) -> set[str]:
    """Return query contigs that are reverse-oriented against their best target.

    Ports d-genies' ``is_contig_well_oriented``: for each query contig, take its
    matches on its best-matching chromosome, keep only "big" matches (euclidean
    length greater than 10% of the longest match *and* at least 1% of
    ``min(contig_len, chrom_len)``), sort them by query mid-point, and check
    whether the target mid-point trends upward.  A contig is reverse-oriented
    when the mean of the consecutive-pair direction signs is ``<= -0.1`` (for a
    single big match, when that match is on the ``-`` strand).  Contigs with no
    big matches are treated as forward (well oriented).

    Parameters
    ----------
    query_names : list[str]
        Query contig names to test.
    matches : dict[tuple[str, str], list[PafRecord]]
        Records keyed by ``(query_name, target_name)``.
    len_map : dict[str, int]
        Sequence lengths for every name.
    best_other : dict[str, str | None]
        Mapping of each query contig to its best-matching target (or ``None``).

    Returns
    -------
    set[str]
        Names of query contigs detected as reverse-oriented.
    """
    reversed_set: set[str] = set()
    for q in query_names:
        t = best_other.get(q)
        if t is None:
            continue
        recs = matches.get((q, t), [])
        if not recs:
            continue

        # (query_mid, target_mid, euclidean_length, strand) per match.
        lines = [
            (
                (r.query_start + r.query_end) / 2.0,
                (r.target_start + r.target_end) / 2.0,
                sqrt(
                    float(r.target_end - r.target_start) ** 2
                    + float(r.query_end - r.query_start) ** 2
                ),
                r.strand,
            )
            for r in recs
        ]
        max_len = max(line[2] for line in lines)
        threshold = 0.01 * min(len_map.get(q, 1), len_map.get(t, 1))
        selected = [
            line for line in lines if line[2] > 0.10 * max_len and line[2] >= threshold
        ]

        if len(selected) > 1:
            selected.sort(key=lambda line: line[0])  # by query mid-point
            signs = [
                1 if selected[i][1] > selected[i - 1][1] else -1
                for i in range(1, len(selected))
            ]
            mean_sign = sum(signs) / len(signs)
            if mean_sign <= -0.1:  # not well oriented
                reversed_set.add(q)
        elif len(selected) == 1:
            # A single big match: orientation is simply its strand.
            if selected[0][3] == '-':
                reversed_set.add(q)
        # Zero big matches: ignore (treated as forward).
    return reversed_set

reverse_complement(seq)

Return the reverse complement of a nucleotide sequence.

Complements A/C/G/T/N (case preserved) and reverses the string. Any other character is left unchanged before reversal.

Parameters:

Name Type Description Default
seq str

Nucleotide sequence.

required

Returns:

Type Description
str

The reverse-complemented sequence.

Source code in dot_explorer/paf_io.py
def reverse_complement(seq: str) -> str:
    """Return the reverse complement of a nucleotide sequence.

    Complements ``A/C/G/T/N`` (case preserved) and reverses the string.  Any
    other character is left unchanged before reversal.

    Parameters
    ----------
    seq : str
        Nucleotide sequence.

    Returns
    -------
    str
        The reverse-complemented sequence.
    """
    return seq.translate(_COMPLEMENT)[::-1]