aboutsummaryrefslogtreecommitdiff
path: root/r_qtl/r_qtl2/types.py
blob: 13b8db4465a7befa0e21df5ce20a6d2c50970922 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"""The types used by our R/qtl2 system."""
from enum import Enum
from pathlib import Path
from dataclasses import field, dataclass
from typing import (Union,
                    Literal,
                    Optional,
                    Sequence,
                    TypeAlias,
                    Annotated)


_ALLELE_COUNTS_BY_CROSSTYPE = {
    "bc": 2, "f2": 2, "riself": 2, "risib": 2, "dh": 2, "haploid": 2, "ail": 2,
    "ail3": 3,
    "riself4": 4, "risib4": 4,
    "dh6": 6,
    "hs": 8, "do": 8, "riself8": 8, "risib8": 8,
    "dof1": 9, # 8 DO founders + 1 base inbred line
    "riself16": 16,
    "magic19": 19
}



PathLike = Union[Path, str]

AlleleLabel: TypeAlias = str

GenoCodeAlleleValue = Union[
    Literal[-1, 0, 1],
    Literal[1, 2, 3],# T0D0: reconcile this to genocodes of 0, 1, 2 instead
    Annotated[float, "Value must be between 0.0 and 2.0"]
]

GenoCode = dict[AlleleLabel, GenoCodeAlleleValue]

# Sex information
SexKey = Union[str, int]# codes used in the covariate file
@dataclass(frozen=True)
class ControlDataSex:
    """Sex information in the control file."""
    covar: Optional[str] = None
    file: Optional[PathLike] = None
    female: Optional[SexKey] = None
    male: Optional[SexKey] = None


# Cross information
CrossType = Literal[
    # T0D0: Look at https://kbroman.org/qtl2/assets/vignettes/input_files.html#Backcross to implement appropriate QC dependent on the crosstype.
    "bc",# Backcross
    "f2",# F2 intercross
    "riself",# RIL by self -- RIL: Recombinant Inbred Lines
    "risib",# RIL by sib
    "dh",# Double haploid
    "haploid",# Haploid
    "ail",# AIL -- Advanced Inbred Lines
    "hs",# Heterogeneous stock
    "do",# Diversity outbreds
    "riself4",# Multi-parent recombinant inbred lines
    "riself8",# Multi-parent recombinant inbred lines
    "riself16",# Multi-parent recombinant inbred lines
    "risib4",# Multi-parent recombinant inbred lines
    "risib8",# Multi-parent recombinant inbred lines
    "magic19",# 19-way MAGIC lines
    "dh6",# 6-way doubled haploids
    "dof1",# DOF1
    "ail3",# 3-way advanced intercross lines
    "genail",# General advanced intercross lines
    "genril",# General recombinant inbred lines
]

class CrossDirection(Enum):
    FORWARD = 0
    REVERSE = 1


@dataclass(frozen=True)
class ShortCrossInfo:
    """Cross information in the control file."""
    # "covar": indicates the name of the column in the covariate data
    covar: Optional[str] = None
    file: Optional[PathLike] = None
    forward_crosses: Sequence[str] = field(default_factory=tuple)
    reverse_crosses: Sequence[str] = field(default_factory=tuple)

    def __post_init__(self):
        """Validate the cross info."""
        # A valid short config MUST have either a `covar` or a `file` source
        # but not both.
        if not (self.covar and self.file):
            raise ValueError("ShortCrossInfo requires either a 'covar' or a "
                             "'file' parameter.")
        if self.covar and self.file:
            raise ValueError("ShortCrossInfo cannot define both 'covar' and "
                             "'file' simultaneously.")


LongCrossInfo: TypeAlias = PathLike
CrossInfo = Union[LongCrossInfo, ShortCrossInfo]


def expected_allele_count_for_crosstype(
        alleles: Sequence[str],
        crosstype: CrossType,
        expected_counts: dict[CrossType, int]
) -> bool:
    """Check that the number of alleles matches what crosstype expects."""
    def __validate__(expected, alleles):
        if expected is not None and len(alleles) != expected:
            raise ValueError(
                f"Cross type '{crosstype}' expects exactly {expected} allele "
                f"labels. Received {len(alleles)}: {alleles}")

    if crosstype.startswith(("genail", "genril")):
        # genail and genril are dynamic (e.g., genail8)
        # Extract the trailing digits to determine expected founder count
        num_part = "".join(filter(str.isdigit, crosstype))
        if num_part:
            return __validate__(int(num_part), alleles)

    return __validate__(expected_counts.get(crosstype), alleles)
            


@dataclass(frozen=True)
class ControlData:
    """Class for the R/qtl2 control data."""
    # File names: Force listings, rather than singular strings
    geno: Sequence[PathLike] = field(default_factory=tuple)
    founder_geno: Sequence[PathLike] = field(default_factory=tuple)
    pheno: Sequence[PathLike] = field(default_factory=tuple)
    covar: Sequence[PathLike] = field(default_factory=tuple)
    phenocovar: Sequence[PathLike] = field(default_factory=tuple)
    gmap: Sequence[PathLike] = field(default_factory=tuple)
    pmap: Sequence[PathLike] = field(default_factory=tuple)

    # X Chromosome
    x_chr: Optional[str] = None

    # Allele labels
    alleles: Sequence[str] = field(default_factory=tuple)

    # Genotype codes
    genotypes: GenoCode = field(default_factory=dict)

    # sex
    sex: Optional[ControlDataSex] = None

    # Cross info
    crosstype: Optional[CrossType] = None
    cross_info: Optional[CrossInfo] = None


    # CSV fields
    na_strings: Sequence[str] = ("-", "NA", "N/A")
    sep: str = ","
    comment_char: Optional[str] = "#"

    def __post_init__(self):
        if self.alleles and self.crosstype:
            expected_allele_count_for_crosstype(
                self.alleles, self.crosstype, _ALLELE_COUNTS_BY_CROSSTYPE)