agglovar 0.0.1.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agglovar/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Agglovar: A toolkit for fast genomic variant transformations and intersects."""
2
+
3
+ __version__ = '0.0.1.dev2'
4
+
5
+ __all__ = [
6
+ 'align',
7
+ 'fa',
8
+ 'join',
9
+ 'io',
10
+ 'kmer',
11
+ 'schema',
12
+ 'seqmatch',
13
+ ]
14
+
15
+ from . import align
16
+ from . import fa
17
+ from . import join
18
+ from . import io
19
+ from . import kmer
20
+ from . import schema
21
+ from . import seqmatch
@@ -0,0 +1,9 @@
1
+ """Alignment operation and handling routines."""
2
+
3
+ __all__ = [
4
+ 'op',
5
+ 'score',
6
+ ]
7
+
8
+ from . import op
9
+ from . import score
agglovar/align/op.py ADDED
@@ -0,0 +1,173 @@
1
+ """Constants and functions for working with alignment operations.
2
+
3
+ Definitions in this subpackage are borrowed from
4
+ `PAV 3+ <https://github.com/BeckLaboratory/pav>`__.
5
+ """
6
+
7
+ __all__ = [
8
+ # Sets of valid characters/codes
9
+ 'INT_STR_SET',
10
+ 'CIGAR_OP_SET',
11
+
12
+ # CIGAR operation codes
13
+ 'M',
14
+ 'I',
15
+ 'D',
16
+ 'N',
17
+ 'S',
18
+ 'H',
19
+ 'P',
20
+ 'EQ',
21
+ 'X',
22
+
23
+ # Operation code sets
24
+ 'CLIP_SET',
25
+ 'ALIGN_SET',
26
+ 'EQX_SET',
27
+
28
+ # Mappings
29
+ 'OP_CHAR',
30
+ 'OP_CHAR_FUNC',
31
+ 'OP_CODE',
32
+
33
+ # Arrays
34
+ 'CONSUMES_QRY_ARR',
35
+ 'CONSUMES_REF_ARR',
36
+ 'ADV_REF_ARR',
37
+ 'ADV_QRY_ARR',
38
+ 'VAR_ARR',
39
+
40
+ # Functions
41
+ 'cigar_to_arr',
42
+ ]
43
+
44
+
45
+ import numpy as np
46
+ from types import MappingProxyType
47
+ from typing import Mapping
48
+
49
+ INT_STR_SET: frozenset[str] = frozenset({'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', })
50
+ """Set of valid integer character strings representing operation codes."""
51
+
52
+ CIGAR_OP_SET: frozenset[str] = frozenset({'M', 'I', 'D', 'N', 'S', 'H', 'P', '=', 'X', })
53
+ """Set of valid operation characters."""
54
+
55
+ M: int = 0
56
+ """Match or mismatch operation code."""
57
+
58
+ I: int = 1 # noqa: E741
59
+ """Insertion operation code."""
60
+
61
+ D: int = 2
62
+ """Deletion operation code."""
63
+
64
+ N: int = 3
65
+ """Skipped region operation code."""
66
+
67
+ S: int = 4
68
+ """Soft clipping operation code."""
69
+
70
+ H: int = 5
71
+ """Hard clipping operation code."""
72
+
73
+ P: int = 6
74
+ """Padding operation code."""
75
+
76
+ EQ: int = 7
77
+ """Sequence match operation code."""
78
+
79
+ X: int = 8
80
+ """Sequence mismatch operation code."""
81
+
82
+ CLIP_SET: frozenset[int] = frozenset({S, H, })
83
+ """Set of clipping operation codes (soft and hard clipping)."""
84
+
85
+ ALIGN_SET: frozenset[int] = frozenset({M, EQ, X, })
86
+ """Set of alignment operation codes (match, sequence match, mismatch)."""
87
+
88
+ EQX_SET: frozenset[int] = frozenset({EQ, X, })
89
+ """Set of exact match/mismatch operation codes."""
90
+
91
+ OP_CHAR: Mapping[int, str] = MappingProxyType({
92
+ M: 'M',
93
+ I: 'I',
94
+ D: 'D',
95
+ N: 'N',
96
+ S: 'S',
97
+ H: 'H',
98
+ P: 'P',
99
+ EQ: '=',
100
+ X: 'X'
101
+ })
102
+ """Mapping from operation codes to their character representations."""
103
+
104
+ OP_CHAR_FUNC = np.vectorize(lambda val: OP_CHAR.get(val, '?'))
105
+ """Vectorized function to convert operation codes to characters."""
106
+
107
+ OP_CODE: Mapping[str, int] = MappingProxyType({
108
+ 'M': M,
109
+ 'I': I,
110
+ 'D': D,
111
+ 'N': N,
112
+ 'S': S,
113
+ 'H': H,
114
+ 'P': P,
115
+ '=': EQ,
116
+ 'X': X
117
+ })
118
+ """Mapping from CIGAR characters to operation codes."""
119
+
120
+ CONSUMES_QRY_ARR: np.typing.NDArray[np.integer] = np.array([M, I, S, EQ, X])
121
+ """Array of operation codes that consume query bases."""
122
+
123
+ CONSUMES_REF_ARR: np.typing.NDArray[np.integer] = np.array([M, D, N, EQ, X])
124
+ """Array of operation codes that consume reference bases."""
125
+
126
+ ADV_REF_ARR: np.typing.NDArray[np.integer] = np.array([M, EQ, X, D])
127
+ """Array of operation codes that advance the reference position."""
128
+
129
+ ADV_QRY_ARR: np.typing.NDArray[np.integer] = np.array([M, EQ, X, I, S, H])
130
+ """Array of operation codes that advance the query position."""
131
+
132
+ VAR_ARR: np.typing.NDArray[np.integer] = np.array([X, I, D])
133
+ """Array of operation codes that introduce variation."""
134
+
135
+
136
+ def cigar_to_arr(
137
+ cigar_str: str
138
+ ) -> np.ndarray:
139
+ """Get a numpy array with two dimensions (dtype int).
140
+
141
+ The first column is the operation codes, the second column is the operation lengths.
142
+
143
+ :param cigar_str: CIGAR string.
144
+
145
+ :returns: Array of operation codes and lengths (dtype int).
146
+
147
+ :raises ValueError: If the CIGAR string is not properly formatted.
148
+ """
149
+ pos = 0
150
+ max_pos = len(cigar_str)
151
+
152
+ op_tuples = list()
153
+
154
+ while pos < max_pos:
155
+
156
+ len_pos = pos
157
+
158
+ while cigar_str[len_pos] in INT_STR_SET:
159
+ len_pos += 1
160
+
161
+ if len_pos == pos:
162
+ raise ValueError(f'Missing length in CIGAR string at index {pos}')
163
+
164
+ if cigar_str[len_pos] not in CIGAR_OP_SET:
165
+ raise ValueError(f'Unknown CIGAR operation {cigar_str[len_pos]}')
166
+
167
+ op_tuples.append(
168
+ (OP_CODE[cigar_str[len_pos]], int(cigar_str[pos:len_pos]))
169
+ )
170
+
171
+ pos = len_pos + 1
172
+
173
+ return np.array(op_tuples)
@@ -0,0 +1,495 @@
1
+ """Alignment routines."""
2
+
3
+ __all__ = [
4
+ 'AFFINE_SCORE_MATCH',
5
+ 'AFFINE_SCORE_MISMATCH',
6
+ 'AFFINE_SCORE_GAP',
7
+ 'AFFINE_SCORE_TS',
8
+ 'DEFAULT_ALIGN_SCORE_MODEL',
9
+ 'ScoreModel',
10
+ 'AffineScoreModel',
11
+ 'get_score_model',
12
+ 'get_affine_by_params',
13
+ ]
14
+
15
+ from abc import ABC, abstractmethod
16
+ import numpy as np
17
+ import re
18
+ from typing import Any, Iterable, Self
19
+
20
+ from . import op
21
+
22
+ AFFINE_SCORE_MATCH = 2.0
23
+ """Default match score."""
24
+
25
+ AFFINE_SCORE_MISMATCH = 4.0
26
+ """Default mismatch score."""
27
+
28
+ AFFINE_SCORE_GAP = ((4.0, 2.0), (24.0, 1.0))
29
+ """Default affine gap scores."""
30
+
31
+ AFFINE_SCORE_TS = None
32
+ """Default template switch score."""
33
+
34
+ DEFAULT_ALIGN_SCORE_MODEL = (
35
+ f'affine::match={AFFINE_SCORE_MATCH},'
36
+ f'mismatch={AFFINE_SCORE_MISMATCH},'
37
+ f'gap={";".join([f"{gap_open}:{gap_extend}" for gap_open, gap_extend in AFFINE_SCORE_GAP])}'
38
+ )
39
+ """Default alignment score model."""
40
+
41
+
42
+ class ScoreModel(ABC):
43
+ """Score model interface."""
44
+
45
+ @abstractmethod
46
+ def match(
47
+ self,
48
+ n: int = 1
49
+ ) -> float:
50
+ """Score match.
51
+
52
+ :param n: Number of matching bases.
53
+
54
+ :returns: Match score.
55
+ """
56
+ pass
57
+
58
+ @abstractmethod
59
+ def mismatch(
60
+ self,
61
+ n: int = 1
62
+ ) -> float:
63
+ """Score mismatch.
64
+
65
+ :param n: Number of mismatched bases.
66
+
67
+ :returns: Mismatch score.
68
+ """
69
+ pass
70
+
71
+ @abstractmethod
72
+ def gap(
73
+ self,
74
+ n: int = 1
75
+ ) -> float:
76
+ """Score gap (insertion or deletion).
77
+
78
+ Compute all affine alignment gap score for each affine
79
+ segment and return the highest value (least negative).
80
+
81
+ :param n: Size of gap.
82
+
83
+ :returns: Gap score.
84
+ """ # noqa: D402
85
+ pass
86
+
87
+ def template_switch(self) -> float:
88
+ """Score a template switch.
89
+
90
+ :returns: Score for a single template switch.
91
+ """
92
+ return 2 * self.gap(50)
93
+
94
+ def score(
95
+ self,
96
+ op_code: int | str,
97
+ op_len: int,
98
+ ) -> float:
99
+ """Score an alignment operation.
100
+
101
+ :param op_code: Operation code. Can be a symbol (e.g. "=", "X", "I", etc.) or the numeric operation code.
102
+ :param op_len: Operation length.
103
+
104
+ :returns: Score for this operation or 0.0 if the operation is not scored (S, H, N, and P operations).
105
+ """
106
+ if op_code in {'=', op.EQ}:
107
+ return self.match(op_len)
108
+
109
+ elif op_code in {'X', op.X}:
110
+ return self.mismatch(op_len)
111
+
112
+ elif op_code in {'I', 'D', op.I, op.D}:
113
+ return self.gap(op_len)
114
+
115
+ elif op_code in {'S', 'H', op.S, op.H}:
116
+ return 0
117
+
118
+ elif op_code in {'M', op.M}:
119
+ raise RuntimeError('Cannot score alignments with match ("M") in CIGAR string (requires "=" and "X")')
120
+
121
+ elif op_code in {'N', 'P', op.N, op.P}:
122
+ return 0
123
+
124
+ else:
125
+ raise RuntimeError(f'Unrecognized CIGAR op code: {op_code}')
126
+
127
+ def score_operations(
128
+ self,
129
+ op_arr: np.ndarray,
130
+ ) -> float:
131
+ """A vectorized implementation of summing scores for affine models.
132
+
133
+ :param op_arr: Array of alignment operations (op_code: first column, op_len: second column).
134
+
135
+ :returns: Sum of scores for each CIGAR operation.
136
+ """
137
+ return np.sum(np.vectorize(self.score)(op_arr[:, 0], op_arr[:, 1]))
138
+
139
+ @abstractmethod
140
+ def mismatch_model(self) -> Self:
141
+ """Get a mismatch model.
142
+
143
+ :returns: A copy of this score model that does not penalize gaps. Used for computing the score of mismatches.
144
+ """
145
+ pass
146
+
147
+ @abstractmethod
148
+ def model_param_string(self) -> str:
149
+ """Get score parameter string.
150
+
151
+ :returns: A parameter string representing this score model.
152
+ """
153
+ pass
154
+
155
+ @abstractmethod
156
+ def __eq__(
157
+ self,
158
+ other: Any
159
+ ) -> bool:
160
+ """Determine if this scoremodel is the same as another.
161
+
162
+ :param other: Other object.
163
+
164
+ :returns: True if this scoremodel is equivalent to `other`.
165
+ """
166
+ pass
167
+
168
+ @abstractmethod
169
+ def __repr__(self):
170
+ """Get a string representation of this score model."""
171
+ pass
172
+
173
+
174
+ class AffineScoreModel(ScoreModel):
175
+ """Affine score model with default values modeled on minimap2 (2.26) default parameters.
176
+
177
+ :ivar match: Match score.
178
+ :ivar mismatch: Mismatch penalty.
179
+ :ivar affine_gap: An iterable containing tuples of two elements (gap open, gap extend).
180
+ :ivar template_switch: Template switch penalty. If none, defaults to 2x the penalty of a 50 bp gap.
181
+ """
182
+
183
+ score_match: float
184
+ score_mismatch: float
185
+ score_affine_gap: tuple
186
+ score_template_switch: float
187
+
188
+ def __init__(
189
+ self,
190
+ match: float = AFFINE_SCORE_MATCH,
191
+ mismatch: float = AFFINE_SCORE_MISMATCH,
192
+ affine_gap: Iterable[tuple[float, float]] = AFFINE_SCORE_GAP,
193
+ template_switch: float = AFFINE_SCORE_TS
194
+ ) -> None:
195
+ """Initialize an affine score model.
196
+
197
+ :param match: Match score.
198
+ :param mismatch: Mismatch score.
199
+ :param affine_gap: Iterable of tuples (gap_open, gap_extend) for affine gap penalties.
200
+ :param template_switch: Template switch score.
201
+ """
202
+ self.score_match = abs(float(match))
203
+ self.score_mismatch = -abs(float(mismatch))
204
+ self.score_affine_gap = tuple((
205
+ (-abs(float(gap_open)), -abs(float(gap_extend)))
206
+ for gap_open, gap_extend in affine_gap
207
+ ))
208
+
209
+ if template_switch is None:
210
+ self.score_template_switch = 2 * self.gap(50)
211
+ else:
212
+ try:
213
+ self.score_template_switch = - np.abs(float(template_switch))
214
+ except ValueError as e:
215
+ raise ValueError(f'template_switch parameter is not numeric: {template_switch}') from e
216
+
217
+ def match(
218
+ self,
219
+ n: int = 1
220
+ ) -> float:
221
+ """Score match.
222
+
223
+ :param n: Number of matching bases.
224
+
225
+
226
+ :returns: Match score.
227
+ """
228
+ return self.score_match * n
229
+
230
+ def mismatch(
231
+ self,
232
+ n: int = 1
233
+ ) -> float:
234
+ """Score mismatch.
235
+
236
+ :param n: Number of mismatched bases.
237
+
238
+ :returns: Mismatch score.
239
+ """
240
+ return self.score_mismatch * n
241
+
242
+ def gap(
243
+ self,
244
+ n: int = 1
245
+ ) -> float:
246
+ """Score gap (insertion or deletion).
247
+
248
+ Compute all affine alignment gap score for each affine
249
+ segment and return the highest value (least negative).
250
+
251
+ :param n: Size of gap.
252
+
253
+ :returns: Gap score.
254
+ """ # noqa: D402
255
+ if n == 0.0:
256
+ return 0.0
257
+
258
+ return np.max(
259
+ [
260
+ gap_open + (gap_extend * n)
261
+ for gap_open, gap_extend in self.score_affine_gap
262
+ ]
263
+ )
264
+
265
+ def template_switch(self) -> float:
266
+ """Score a template switch.
267
+
268
+ :returns: Score for a single template switch.
269
+ """
270
+ return self.score_template_switch
271
+
272
+ def mismatch_model(self) -> Self:
273
+ """Get a mismatch model.
274
+
275
+ :returns: A copy of this score model that does not penalize gaps. Used for computing the score of mismatches.
276
+ """
277
+ return AffineScoreModel(
278
+ match=self.score_match,
279
+ mismatch=self.score_mismatch,
280
+ affine_gap=[(0.0, 0.0)],
281
+ template_switch=self.score_template_switch
282
+ )
283
+
284
+ def score_operations(
285
+ self,
286
+ op_arr: np.ndarray,
287
+ ) -> float:
288
+ """A vectorized implementation of summing scores for affine models.
289
+
290
+ :param op_arr: Array of alignment operations (op_code: first column, op_len: second column).
291
+
292
+ :returns: Sum of scores for each CIGAR operation.
293
+ """
294
+ if np.any(op_arr[:, 0] == op.M):
295
+ raise RuntimeError('Cannot score alignments with match ("M") in CIGAR string (requires "=" and "X")')
296
+
297
+ # Score gaps
298
+ gap_arr = op_arr[(op_arr[:, 0] == op.D) | (op_arr[:, 0] == op.I), 1]
299
+
300
+ gap_score = np.full((gap_arr.shape[0], 2), -np.inf)
301
+
302
+ for gap_open, gap_extend in self.score_affine_gap:
303
+ gap_score[:, 1] = gap_open + gap_arr * gap_extend
304
+ gap_score[:, 0] = np.max(gap_score, axis=1)
305
+
306
+ return float(
307
+ np.sum(op_arr[:, 1] * (op_arr[:, 0] == op.EQ) * self.score_match)
308
+ + np.sum(op_arr[:, 1] * (op_arr[:, 0] == op.X) * self.score_mismatch)
309
+ # If no gap penalties (i.e. mismatch model), then gap_score is -inf (set to 0.0)
310
+ + np.nan_to_num(gap_score[:, 0], neginf=0.0).sum()
311
+ )
312
+
313
+ def model_param_string(self) -> str:
314
+ """Get score parameter string.
315
+
316
+ :returns: A parameter string representing this score model.
317
+ """
318
+ return (
319
+ f'affine::match={self.score_match},'
320
+ f'mismatch={self.score_mismatch},'
321
+ f'gap={";".join([f"{gap_open}:{gap_extend}" for gap_open, gap_extend in self.score_affine_gap])}'
322
+ )
323
+
324
+ def __eq__(
325
+ self,
326
+ other: Any
327
+ ) -> bool:
328
+ """Determine if this scoremodel is the same as another.
329
+
330
+ :param other: Other object.
331
+
332
+ :returns: True if this scoremodel is equivalent to `other`.
333
+ """
334
+ if other is None or not isinstance(other, self.__class__):
335
+ return False
336
+
337
+ return (
338
+ self.score_match == other.score_match
339
+ and self.score_mismatch == other.score_mismatch
340
+ and self.score_affine_gap == other.score_affine_gap
341
+ and self.score_template_switch == other.score_template_switch
342
+ )
343
+
344
+ def __repr__(self):
345
+ """Get a string representation of this score model."""
346
+ gap_str = ';'.join([f'{abs(gap_open)}:{abs(gap_extend)}' for gap_open, gap_extend in self.score_affine_gap])
347
+
348
+ return (
349
+ f'AffineScoreModel('
350
+ f'match={self.score_match},'
351
+ f'mismatch={-self.score_mismatch},'
352
+ f'gap={gap_str},'
353
+ f'ts={-self.score_template_switch}'
354
+ f')'
355
+ )
356
+
357
+
358
+ def get_score_model(
359
+ param_string: str = None
360
+ ) -> ScoreModel:
361
+ """Get score model from a string of alignment parameters.
362
+
363
+ :param param_string: Parameter string. Can be None or an empty string (default model is used). If
364
+ the string is an instance of `ScoreModel`, then the `ScoreModel` object is returned.
365
+ Otherwise, the string is parsed and a score model object is returned.
366
+
367
+ :returns: A `ScoreModel` object.
368
+ """
369
+ if isinstance(param_string, ScoreModel):
370
+ return param_string
371
+
372
+ if param_string is not None:
373
+ param_string = param_string.strip()
374
+
375
+ if param_string is None or len(param_string) == 0:
376
+ param_string = DEFAULT_ALIGN_SCORE_MODEL
377
+
378
+ if '::' in param_string:
379
+ model_type, model_params = re.split(r'::', param_string, maxsplit=1)
380
+
381
+ else:
382
+ model_type, model_params = 'affine', param_string
383
+
384
+ if model_type == 'affine':
385
+ return get_affine_by_params(model_params)
386
+
387
+ raise RuntimeError(f'Unrecognized score model type: {model_type}')
388
+
389
+
390
+ def get_affine_by_params(
391
+ param_string: str
392
+ ) -> AffineScoreModel:
393
+ """Parse a string to get alignment parameters from it.
394
+
395
+ :param param_string: Parameter string.
396
+
397
+ :returns: A configured AffineScoreModel.
398
+ """
399
+ # Set defaults
400
+ params = [
401
+ AFFINE_SCORE_MATCH,
402
+ AFFINE_SCORE_MISMATCH,
403
+ AFFINE_SCORE_GAP,
404
+ AFFINE_SCORE_TS
405
+ ]
406
+
407
+ keys = ['match', 'mismatch', 'gap', 'ts']
408
+
409
+ # Sanitize parameter string
410
+ if param_string is not None:
411
+ param_string = param_string.strip()
412
+
413
+ if len(param_string) == 0:
414
+ param_string = None
415
+
416
+ if param_string is None:
417
+ param_string = DEFAULT_ALIGN_SCORE_MODEL
418
+
419
+ # Parse param string
420
+ param_pos = 0
421
+
422
+ for tok in param_string.split(','):
423
+ tok = tok.strip()
424
+
425
+ if len(tok) == 0:
426
+ param_pos += 1
427
+ continue # Accept default for missing
428
+
429
+ if '=' in tok:
430
+ param_pos = None # Do not allow positional parameters after named ones
431
+
432
+ key, val = tok.split('=')
433
+
434
+ key = key.strip()
435
+ val = val.strip()
436
+
437
+ else:
438
+ if param_pos is None:
439
+ raise RuntimeError(
440
+ f'Named parameters (with "=") must be specified after positional parameters (no "="): '
441
+ f'{param_string}'
442
+ )
443
+
444
+ key = keys[param_pos]
445
+ val = tok
446
+
447
+ param_pos += 1
448
+
449
+ if key in {'match', 'mismatch', 'ts'}:
450
+ try:
451
+ val = abs(float(val))
452
+ except ValueError:
453
+ raise RuntimeError(f'Unrecognized alignment parameter: {key} (allowed: match, mismatch, gap, ts)')
454
+
455
+ if key == 'match':
456
+ params[0] = val
457
+
458
+ elif key == 'mismatch':
459
+ params[1] = val
460
+
461
+ elif key == 'gap':
462
+
463
+ gap_list = list()
464
+
465
+ for gap_pair in val.split(';'):
466
+ gap_tok = gap_pair.split(':')
467
+
468
+ if len(gap_tok) != 2:
469
+ raise RuntimeError(
470
+ f'Unrecognized gap format: Expected "open:extend" for each element '
471
+ f'(multiple pairs separated by ";"): "{gap_pair}" in {val}'
472
+ )
473
+
474
+ try:
475
+ gap_open = abs(float(gap_tok[0].strip()))
476
+ gap_extend = abs(float(gap_tok[1].strip()))
477
+
478
+ except ValueError:
479
+ raise RuntimeError(
480
+ f'Unrecognized gap format: Expected integer values for "open:extend" '
481
+ f'for each gap cost element: {val}'
482
+ )
483
+
484
+ gap_list.append((gap_open, gap_extend))
485
+
486
+ params[2] = tuple(gap_list)
487
+
488
+ elif key == 'ts':
489
+ params[3] = val
490
+
491
+ else:
492
+ raise RuntimeError(f'Unrecognized alignment parameter: {key} (allowed: match, mismatch, gap, ts)')
493
+
494
+ # Return alignment object
495
+ return AffineScoreModel(*params)