File size: 36,472 Bytes
6d34920
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
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
from collections import OrderedDict
from typing import Union, List, Generator, Tuple
from Bio import SeqIO
from Bio.SeqRecord import SeqRecord
import pandas as pd

from abnumber.alignment import Alignment
from abnumber.common import _anarci_align, _validate_chain_type, SUPPORTED_SCHEMES, SUPPORTED_CDR_DEFINITIONS, \
    is_integer, SCHEME_BORDERS, _get_unique_chains
from abnumber.exceptions import ChainParseError
import numpy as np
from Bio.Seq import Seq

from abnumber.position import Position


class Chain:
    """
    Antibody chain aligned to a chosen antibody numbering scheme

    :example:

    >>> from abnumber import Chain
    >>>
    >>> seq = 'QVQLQQSGAELARPGASVKMSCKASGYTFTRYTMHWVKQRPGQGLEWIGYINPSRGYTNYNQKFKDKATLTTDKSSSTAYMQLSSLTSEDSAVYYCARYYDDHYCLDYWGQGTTLTVSSAKTTAPSVYPLA'
    >>> chain = Chain(seq, scheme='imgt')
    >>> chain
    QVQLQQSGAELARPGASVKMSCKASGYTFTRYTMHWVKQRPGQGLEWIGYINPSRGYTNYNQKFKDKATLTTDKSSSTAYMQLSSLTSEDSAVYYCARYYDDHYCLDYWGQGTTLTVSS
                             ^^^^^^^^                 ^^^^^^^^                                      ^^^^^^^^^^^^

    Chain can be iterated:

    >>> for pos, aa in chain:
    >>>     print(pos, aa)
    H1  Q
    H2  V
    H3  Q
    H4  L
    H5  Q
    ...

    Chain can also be indexed and sliced using scheme numbering:

    >>> chain['5']
    'Q'
    >>> for pos, aa in chain['H2':'H5']:
    >>>     print(pos, aa)
    H2  V
    H3  Q
    H4  L
    H5  Q

    :param sequence: Unaligned string sequence
    :param name: Optional sequence identifier
    :param scheme: Numbering scheme: One of ``imgt``, ``chothia``, ``kabat``, ``aho``
    :param cdr_definition: Numbering scheme to be used for definition of CDR regions. Same as ``scheme`` by default.
                           One of ``imgt``, ``chothia``, ``kabat``, ``north``. Required for ``aho``.
    :param assign_germline: Assign germline name using ANARCI based on best sequence identity
    :param allowed_species: Allowed species for germline assignment. Use ``None`` to allow all species, or one or more of: ``'human', 'mouse','rat','rabbit','rhesus','pig','alpaca'``
    :param aa_dict: (Internal use only) Create Chain object directly from dictionary of region objects (internal use)
    :param tail: (Internal use only) Constant region sequence
    :param species: (Internal use only) Species as identified by ANARCI
    :param germline: (Internal use only) Germline as identified by ANARCI
    """

    def __init__(self, sequence, scheme, cdr_definition=None, name=None, assign_germline=False, allowed_species=None, **kwargs):
        aa_dict = kwargs.pop('aa_dict', None)
        chain_type = kwargs.pop('chain_type', None)
        tail = kwargs.pop('tail', None)
        species = kwargs.pop('species', None)
        v_gene = kwargs.pop('v_gene', None)
        j_gene = kwargs.pop('j_gene', None)
        if isinstance(allowed_species, str):
            allowed_species = [allowed_species]
        if len(kwargs):
            raise TypeError(f'Argument not recognized: {", ".join(kwargs)}')
        if aa_dict is not None:
            if sequence is not None:
                raise ChainParseError('Only one of aa_dict= and sequence= can be provided')
            assert isinstance(aa_dict, dict), f'Expected dict, got: {type(aa_dict)}'
            assert tail is not None
            assert chain_type is not None
        else:
            if sequence is None:
                raise ChainParseError('Expected sequence, got None')
            if not isinstance(sequence, str) and not isinstance(sequence, Seq):
                raise ChainParseError(f'Expected string or Seq, got {type(sequence)}: {sequence}')
            if '-' in sequence:
                raise ChainParseError(f'Please provide an unaligned sequence, got: {sequence}')
            if chain_type is not None:
                raise ChainParseError('Do not use chain_type= when providing sequence=, it will be inferred automatically')
            if tail is not None:
                raise ChainParseError('Do not use tail= when providing sequence=, it will be inferred automatically')
            if isinstance(sequence, Seq):
                sequence = str(sequence)
            results = _anarci_align(sequence, scheme=scheme, allowed_species=allowed_species, assign_germline=assign_germline)
            if len(results) > 1:
                raise ChainParseError(f'Found {len(results)} antibody domains in sequence: "{sequence}"')
            aa_dict, chain_type, tail, species, v_gene, j_gene = results[0]

        _validate_chain_type(chain_type)

        self.name: str = name
        """User-provided sequence identifier"""
        self.chain_type: str = chain_type
        """Chain type as identified by ANARCI: ``H`` (heavy), ``K`` (kappa light) or ``L`` (lambda light)
        
        See also :meth:`Chain.is_heavy_chain` and :meth:`Chain.is_light_chain`.
        """
        self.scheme: str = scheme
        """Numbering scheme used to align the sequence"""
        self.cdr_definition: str = cdr_definition or scheme
        """Numbering scheme to be used for definition of CDR regions (same as ``scheme`` by default)"""
        self.tail: str = tail
        """Constant region sequence"""
        self.species: str = species
        """Species as identified by ANARCI"""
        self.v_gene: str = v_gene
        """V gene germline as identified by ANARCI (if assign_germline is True)"""
        self.j_gene: str = j_gene
        """J gene germline as identified by ANARCI (if assign_germline is True)"""

        self.fr1_dict = OrderedDict()
        self.cdr1_dict = OrderedDict()
        self.fr2_dict = OrderedDict()
        self.cdr2_dict = OrderedDict()
        self.fr3_dict = OrderedDict()
        self.cdr3_dict = OrderedDict()
        self.fr4_dict = OrderedDict()

        self._init_from_dict(aa_dict, allowed_species=allowed_species)

    def _init_from_dict(self, aa_dict, allowed_species):
        if self.scheme not in SUPPORTED_SCHEMES:
            raise NotImplementedError(f'Scheme "{self.scheme}" is not supported. Available schemes: {", ".join(SUPPORTED_SCHEMES)}')
        if self.cdr_definition in ['aho']:
            raise ValueError('CDR regions are not defined for AHo, '
                             'you need to specify cdr_definition="chothia" or another scheme for CDR extraction.')
        if self.cdr_definition not in SUPPORTED_CDR_DEFINITIONS:
            raise NotImplementedError(f'CDR definition "{self.scheme}" is not supported. Available definitions: {", ".join(SUPPORTED_SCHEMES)}')
        # list of region start positions
        borders = SCHEME_BORDERS[self.cdr_definition] if self.cdr_definition in SCHEME_BORDERS else SCHEME_BORDERS[f'{self.cdr_definition}_{self.chain_type}']

        regions_list = [self.fr1_dict, self.cdr1_dict, self.fr2_dict, self.cdr2_dict, self.fr3_dict, self.cdr3_dict, self.fr4_dict]
        region_idx = 0

        sorted_positions = sorted(aa_dict.keys())

        cdr_definition_ready = True
        for pos in sorted_positions:
            assert pos.scheme == self.scheme, f'Schemes of provided position ({pos.scheme}) does not match Chain scheme ({self.scheme})'
            if pos.cdr_definition != self.cdr_definition:
                cdr_definition_ready = False

        if cdr_definition_ready:
            combined_aa_dict = aa_dict
        else:
            seq = ''.join(aa_dict[pos] for pos in sorted_positions)
            renumbered_aa_dict = _anarci_align(
                seq,
                scheme=self.cdr_definition if self.cdr_definition != 'north' else 'chothia',
                allowed_species=allowed_species
            )[0][0]
            cdr_definition_positions = [pos.number for pos in sorted(renumbered_aa_dict.keys())]
            combined_aa_dict = {}
            for orig_pos, cdr_definition_position in zip(sorted_positions, cdr_definition_positions):
                aa = aa_dict[orig_pos]
                pos = orig_pos.copy()
                pos.set_cdr_definition(self.cdr_definition, cdr_definition_position)
                combined_aa_dict[pos] = aa

        for pos in sorted(combined_aa_dict.keys()):
            assert isinstance(pos, Position), f'Expected Position object, got {type(pos)}: {pos}'
            aa = combined_aa_dict[pos].upper().strip()
            if aa in [None, '*', '-', '', '.']:
                continue
            while pos.cdr_definition_position >= borders[region_idx]:
                region_idx += 1
            regions_list[region_idx][pos] = aa

    def __repr__(self):
        return self.format()

    def __str__(self):
        return self.seq

    def __iter__(self):
        yield from self.positions.items().__iter__()

    def __getitem__(self, item):
        if isinstance(item, slice):
            if item.step is not None and item.step != 1:
                raise IndexError(f'Slicing with step != 1 is not implemented, got: {item}')
            return self.slice(start=item.start, stop=item.stop)
        pos = self._parse_position(item)
        return self.positions[pos]

    def __len__(self):
        return len(self.positions)

    def __hash__(self):
        return hash(self.positions)

    def __eq__(self, other):
        """Check chain equality. Only checks scheme, aligned sequence and tail sequence, ignores name, metadata and CDR definitions."""
        assert isinstance(other, Chain), f'Can only compare Chain to another Chain, got {type(other)}: {other}'
        return self.positions == other.positions and self.tail == other.tail

    @classmethod
    def to_fasta(cls, chains, path_or_fd, keep_tail=False, description=''):
        """Save multiple chains to FASTA"""
        if isinstance(chains, Chain):
            records = chains.to_seq_record(keep_tail=keep_tail, description=description)
        else:
            records = (chain.to_seq_record(keep_tail=keep_tail, description=description) for chain in chains)
        return SeqIO.write(records, path_or_fd, 'fasta-2line')

    @classmethod
    def from_fasta(cls, path_or_handle, scheme, cdr_definition=None, as_series=False, as_generator=False, **kwargs) -> Union[List['Chain'], pd.Series, Generator['Chain', None, None]]:
        """Read multiple chains from FASTA"""
        generator = (cls(record.seq, name=record.name, scheme=scheme, cdr_definition=cdr_definition, **kwargs)
                     for record in SeqIO.parse(path_or_handle, 'fasta'))
        if as_generator:
            return generator
        chains = list(generator)
        if as_series:
            return pd.Series(chains, index=[c.name for c in chains])
        return chains

    def to_seq_record(self, keep_tail=False, description=''):
        """Create BioPython SeqRecord object from this Chain"""
        if not self.name:
            raise ValueError('Name needs to be present to convert to a SeqRecord')
        seq = Seq(self.seq + self.tail if keep_tail else self.seq)
        return SeqRecord(seq, id=self.name, description=description)

    @classmethod
    def to_anarci_csv(cls, chains: List['Chain'], path):
        """Save multiple chains to ANARCI-like CSV"""
        df = cls.to_dataframe(chains)
        df.to_csv(path)

    @classmethod
    def to_dataframe(cls, chains: List['Chain']):
        """Produce a Pandas dataframe with aligned chain sequences in the columns

        Note: Contains only positions (columns) that are present in the provided chains,
        so number of columns can differ based on the input.
        """
        series_list = [chain.to_series() for chain in chains]

        # Each chain can have a different set of positions
        # so we need to sort the columns to make sure they are in the right order
        # this is using the correct Position sorting
        columns = set(c for series in series_list for c in series.index)
        prop_columns = [c for c in columns if not isinstance(c, Position)]
        position_columns = sorted([c for c in columns if isinstance(c, Position)])
        # Columns can come from K and L chain, so we need to convert them to string and remove duplicates here
        position_columns_str = pd.Series(
            [pos.format(chain_type=False) for pos in position_columns]
        ).drop_duplicates().to_list()

        # Get full list of string columns
        columns_str = prop_columns + position_columns_str

        # Reindex each series using ordered list of string columns
        series_list_ordered = []
        for series in series_list:
            series.index = series.index.map(lambda pos: pos.format(chain_type=False))
            series_list_ordered.append(series.reindex(columns_str))

        df = pd.DataFrame(series_list_ordered)[columns_str].fillna('-')
        df.index.name = 'Id'

        return df

    def to_series(self):
        props = {
            'chain_type': self.chain_type,
            'species': self.species
        }
        return pd.Series({**props, **self.positions}, name=self.name)

    @classmethod
    def from_series(cls, series, scheme, cdr_definition=None) -> 'Chain':
        chain_type = series['chain_type']
        species = series.get('species')
        position_index = [c for c in series.index if c[:1].isnumeric()]
        aa_dict = {Position.from_string(pos, chain_type=chain_type, scheme=scheme): aa
                   for pos, aa in series[position_index].items() if aa != '-' and not pd.isna(aa)}
        return cls(sequence=None, aa_dict=aa_dict, name=series.name, scheme=scheme, cdr_definition=cdr_definition,
                   chain_type=chain_type, species=species, tail='')

    @classmethod
    def from_anarci_csv(cls, path, scheme, cdr_definition=None, as_series=False) -> Union[List['Chain'], pd.Series]:
        df = pd.read_csv(path, index_col=0)
        return cls.from_dataframe(df, scheme=scheme, cdr_definition=cdr_definition, as_series=as_series)

    @classmethod
    def from_dataframe(cls, df, scheme, cdr_definition=None, as_series=False) -> Union[List['Chain'], pd.Series]:
        chains = [cls.from_series(series, scheme=scheme, cdr_definition=cdr_definition) for i, series in df.iterrows()]
        if as_series:
            return pd.Series(chains, index=[c.name for c in chains])
        return chains

    def format(self, method='wide', **kwargs):
        """Format sequence to string

        :param method: use ``"wide"`` for :meth:`Chain.format_wide` or ``"tall"`` for :meth:`Chain.format_tall()`
        :return: formatted string
        """
        if method == 'wide':
            return self.format_wide(**kwargs)
        elif method == 'tall':
            return self.format_tall(**kwargs)
        raise ValueError(f'Use method="wide" or method="tall", unknown method: "{method}"')
    
    def print(self, method='wide', **kwargs):
        """Print string representation using :meth:`Chain.format`

        By default, produces "wide" format with sequence on first line and CDR regions higlighted with ``^`` on second line:

        >>> chain.print()
        QVQLQQSGAELARPGASVKMSCKASGYTFTRYTMHWVKQRPGQGLEWIGYINPSRGYTNYNQKFKDKATLTTDKSSSTAYMQLSSLTSEDSAVYYCARYYDDHYCLDYWGQGTTLTVSS
                                 ^^^^^^^^                 ^^^^^^^^                                      ^^^^^^^^^^^^

        :param method: use ``"wide"`` for :meth:`Chain.format_wide` or ``"tall"`` for :meth:`Chain.format_tall()`
        """
        print(self.format(method=method, **kwargs))

    def format_tall(self, columns=5):
        """Create string with one position per line, showing position numbers and amino acids

        :return: formatted string
        """
        height = int(np.ceil(len(self) / columns))
        rows = [''] * height
        for column, start in enumerate(range(0, len(self), height)):
            chain_slice = self.raw[start:start+height]
            for row, (pos, aa) in enumerate(chain_slice):
                rows[row] = rows[row].ljust(column * 15)
                pos_format = (pos.get_region() + ' ' if pos.is_in_cdr() else '') + pos.format()
                rows[row] += f'{pos_format.rjust(9)} {aa}'

        return '\n'.join(rows)

    def print_tall(self, columns=5):
        """Print string representation using :meth:`Chain.format_tall`

        >>> chain.print_tall()
        FR1 H1    Q
        FR1 H2    V
        FR1 H3    Q
        FR1 H4    L
        FR1 H5    Q
        FR1 H6    Q
        FR1 H7    S
        ...
        """
        print(self.format_tall(columns=columns))

    def format_wide(self, numbering=False):
        """Create string with sequence on first line and CDR regions higlighted with `^` on second line

        :param numbering: Add position numbers on top
        :return: formatted string
        """
        lines = []
        if numbering:

            first_order = ''
            prev_number = None
            after_double_digit = False
            for pos in self.positions:
                number = str(pos.number // 10)
                if number != prev_number:
                    if after_double_digit:
                        # Special case: when double digits follow another double digits, do not print the first digit
                        number = number[1:]
                    first_order += number
                    if len(number) > 1:
                        after_double_digit = True
                else:
                    if after_double_digit:
                        # Special case: After 10, 11, etc, skip adding the space
                        after_double_digit = False
                    else:
                        first_order += ' '
                prev_number = number

            lines.append(first_order)
            lines.append(''.join(str(pos.number % 10) for pos in self.positions))
            letters = ''.join(pos.letter or ' ' for pos in self.positions)
            if letters.strip():
                lines.append(letters)
        lines.append(self.seq)
        if self.cdr_definition == 'kabat':
            lines.append(''.join('^' if pos.is_in_cdr() else ("°" if pos.is_in_vernier() else ' ') for pos in self.positions))
        else:
            lines.append(''.join('^' if pos.is_in_cdr() else ' ' for pos in self.positions))
        return '\n'.join(lines)

    def print_wide(self, numbering=False):
        """Print string representation using :meth:`Chain.format_wide`

        >>> chain.print_wide()
        QVQLQQSGAELARPGASVKMSCKASGYTFTRYTMHWVKQRPGQGLEWIGYINPSRGYTNYNQKFKDKATLTTDKSSSTAYMQLSSLTSEDSAVYYCARYYDDHYCLDYWGQGTTLTVSS
                                 ^^^^^^^^                 ^^^^^^^^                                      ^^^^^^^^^^^^
        """
        print(self.format_wide(numbering=numbering))

    def is_heavy_chain(self):
        """Check if this chain is heavy chain (``chain_type=="H"``)"""
        return self.chain_type == 'H'

    def is_light_chain(self):
        """Check if this chain is light chain (``chain_type=="K" or chain_type=="L"``)"""
        return self.is_lambda_light_chain() or self.is_kappa_light_chain()

    def is_lambda_light_chain(self):
        """Check if this chain is lambda light chain (``chain_type=="L"``)"""
        return self.chain_type == 'L'

    def is_kappa_light_chain(self):
        """Check if this chain is kappa light chain (``chain_type=="K"``)"""
        return self.chain_type == 'K'

    def align(self, *other) -> 'Alignment':
        """Align this chain to other chains by using their existing numbering

        >>> from abnumber import Chain
        >>>
        >>> seq1 = 'QVQLQQSGAELARPGASVKMSCKASGYTFTRYTMHWVKQRPGQGLEWIGYINPSRGYTNYNQKFKDKATLTTDKSSSTAYMQLSSLTSEDSAVYYCARYYDDHYCLDYWGQGTTLTVSSAKTTAP'
        >>> chain1 = Chain(seq1, scheme='imgt')
        >>>
        >>> seq2 = 'QVQLVQSGAELDRPGATVKMSCKASGYTTTRYTMHWVKQRPGQGLDWIGYINPSDRSYTNYNQKFKDKATLTTDKSSSTAYMQKTSLTSEDSAVYYCARYYDDYLDRWGQGTTLTVSSAKTTAP'
        >>> chain2 = Chain(seq2, scheme='imgt')
        >>>
        >>> alignment = chain1.align(chain2)
        >>> print(alignment.format())
        QVQLQQSGAELARPGASVKMSCKASGYTFTRYTMHWVKQRPGQGLEWIGYINPS-RGYTNYNQKFKDKATLTTDKSSSTAYMQLSSLTSEDSAVYYCARYYDDHYCLDYWGQGTTLTVSS
        ||||.||||||.||||+|||||||||||.||||||||||||||||+||||||||.|.||||||||||||||||||||||||||.+|||||||||||||||||....||.|||||||||||
        QVQLVQSGAELDRPGATVKMSCKASGYTTTRYTMHWVKQRPGQGLDWIGYINPSDRSYTNYNQKFKDKATLTTDKSSSTAYMQKTSLTSEDSAVYYCARYYD--DYLDRWGQGTTLTVSS
                                 ^^^^^^^^                 ^^^^^^^^^                                      ^^^^^^^^^^^^

        :param other: The :class:`Chain` object to align, can be repeated to create a multiple sequence alignment
        :return: :class:`Alignment` object
        """
        pos_dicts = [self.positions]
        for chain in other:
            assert isinstance(chain, Chain), f'Expected Chain object, got {type(chain)}: {chain}'
            pos_dicts.append(chain.positions)

        unique_cdr_definitions = set(pos.cdr_definition for pos_dict in pos_dicts for pos in pos_dict.keys())
        assert len(unique_cdr_definitions) <= 1, f'Aligned chains should use the same CDR definitions, got: {unique_cdr_definitions}'

        shared_pos = sorted(set(pos for pos_dict in pos_dicts for pos in pos_dict.keys()))
        residues = [tuple(pos_dict.get(pos, '-') for pos_dict in pos_dicts) for pos in shared_pos]
        return Alignment(shared_pos, residues, chain_type=self.chain_type, scheme=self.scheme)

    def clone(self, replace_seq: str = None):
        """Create a copy of this chain, optionally with a replacement sequence
        
        :param replace_seq: Optional replacement sequence, needs to be the same length
        :return: new Chain object
        """
        return self.slice(replace_seq=replace_seq)

    def slice(self, replace_seq: str = None, start: Union[str, int, 'Position'] = None,
              stop: Union[str, int, 'Position'] = None, stop_inclusive: bool = True, allow_raw: bool = False):
        """Create a slice of this chain, optionally with a replacement sequence that is placed into the same numbering

        You can also slice directly using ``chain['111':'112A']`` or ``chain.raw[10:20]``.

        :param replace_seq: Optional replacement sequence, needs to be the same length
        :param start: Optional slice start position (inclusive), :class:`Position` or string (e.g. '111A')
        :param stop: Optional slice stop position (inclusive), :class:`Position` or string (e.g. '112A')
        :param stop_inclusive: Include stop position in slice
        :param allow_raw: Allow unaligned numeric indexing from 0 to length of sequence - 1
        :return: new Chain object
        """
        aa_dict = {}
        positions = self.positions
        if replace_seq is not None:
            assert len(replace_seq) == len(positions), 'Sequence needs to be the same length'

        start = self._parse_position(start, allow_raw=allow_raw) if start is not None else None
        stop = self._parse_position(stop, allow_raw=allow_raw) if stop is not None else None

        for i, (pos, aa) in enumerate(positions.items()):
            if start is not None and pos < start:
                continue
            if stop is not None and (pos > stop or (not stop_inclusive and pos >= stop)):
                break
            aa_dict[pos] = replace_seq[i] if replace_seq is not None else aa

        return Chain(
            sequence=None,
            aa_dict=aa_dict,
            name=self.name,
            scheme=self.scheme,
            chain_type=self.chain_type,
            cdr_definition=self.cdr_definition,
            tail=self.tail,
            species=self.species,
            v_gene=self.v_gene,
            j_gene=self.j_gene
        )

    def renumber(self, scheme=None, cdr_definition=None, allowed_species=None):
        """Return copy of this chain aligned using a different numbering scheme or CDR definition

        :param scheme: Change numbering scheme: One of ``imgt``, ``chothia``, ``kabat``, ``aho``.
        :param cdr_definition: Change CDR definition scheme: One of ``imgt``, ``chothia``, ``kabat``, ``north``.
        :param allowed_species: ``None`` to allow all species, or one or more of: ``'human', 'mouse','rat','rabbit','rhesus','pig','alpaca'``
        """

        return Chain(
            self.seq + self.tail,
            name=self.name,
            allowed_species=allowed_species,
            scheme=scheme or self.scheme,
            cdr_definition=cdr_definition or scheme or self.cdr_definition,
            assign_germline=self.v_gene is not None
        )

    def graft_cdrs_onto(self, other: 'Chain', backmutate_vernier=False, backmutations: List[Union['Position',str]] = [], name: str = None) -> 'Chain':
        """Graft CDRs from this Chain onto another chain

        :param other: Chain to graft CDRs into (source of frameworks and tail sequence)
        :param backmutate_vernier: Also graft all Kabat Vernier positions from this chain (perform backmutations)
        :param backmutations: List of positions that should additionally be grafted from this chain (str or or :class:`Position`)
        :param name: Name of new Chain. If not provided, use name of this chain.
        :return: Chain with CDRs grafted from this chain and frameworks from the given chain
        """
        assert self.scheme == other.scheme, \
            f'Sequences need to have the same numbering scheme, got {self.scheme} and {other.scheme}'
        assert self.cdr_definition == other.cdr_definition, \
            f'Sequences need to have the same CDR definition, got {self.cdr_definition} and {other.cdr_definition}'
        assert self.chain_type == other.chain_type, \
            f'Sequences need to have the same chain type, got {self.chain_type} and {other.chain_type}'

        backmutations = [self._parse_position(pos) for pos in backmutations]

        grafted_dict = {pos: aa for pos, aa in other if not pos.is_in_cdr()}
        for pos, aa in self:
            if pos.is_in_cdr() or (backmutate_vernier and pos.is_in_vernier()) or pos in backmutations:
                grafted_dict[pos] = aa

        return Chain(sequence=None, aa_dict=grafted_dict, name=name or self.name, chain_type=self.chain_type,
                     scheme=self.scheme, cdr_definition=self.cdr_definition, tail=other.tail,
                     v_gene=other.v_gene, j_gene=other.j_gene)

    def graft_cdrs_onto_human_germline(self, v_gene=None, j_gene=None,
                                       backmutate_vernier=False, backmutations: List[Union['Position',str]] = []):
        """Graft CDRs from this Chain onto the nearest human germline sequence

        :param v_gene: Use defined V germline allele (e.g. IGHV1-18*01), gene (e.g. IGHV1-18) or family (e.g. IGHV1)
        :param j_gene: Use defined J germline allele (e.g. IGHJ1*01) or gene (e.g. IGHJ1)
        :param backmutate_vernier: Also graft all Kabat Vernier positions from this chain (perform backmutations)
        :param backmutations: List of positions that should additionally be grafted from this chain (str or or :class:`Position`)
        :return: Chain with CDRs grafted from this chain and frameworks from TODO
        """
        germline_chain = self.find_merged_human_germline(v_gene=v_gene, j_gene=j_gene)

        if self.scheme != 'imgt' or self.cdr_definition != 'imgt':
            germline_chain = germline_chain.renumber(self.scheme, self.cdr_definition)

        return self.graft_cdrs_onto(germline_chain, backmutate_vernier=backmutate_vernier, backmutations=backmutations)

    def _parse_position(self, position: Union[int, str, 'Position'], allow_raw=False):
        """Create :class:`Position` key object from string or int.

        Note: The position should only be used for indexing, CDR definition is not preserved!

        :param position: Numeric or string position representation
        :param allow_raw: Also allow unaligned numeric (int) indexing from 0 to length of sequence - 1
        :return: new Position object, should only be used for indexing, CDR definition is not preserved!
        """
        if isinstance(position, str):
            return Position.from_string(position, chain_type=self.chain_type, scheme=self.scheme)
        if isinstance(position, Position):
            return position
        try:
            position = int(position)
        except TypeError:
            raise IndexError(f'Invalid position key, expected Position, string or integer, got {type(position)}: "{position}"')
        if not allow_raw:
            raise IndexError("Use chain.raw[i] for raw numeric indexing or pass allow_raw=True. "
                             "For named position indexing, use string (e.g. chain['111A'] or chain['H111A'])")
        if position >= len(self.positions):
            return None
        return self.get_position_by_raw_index(position)

    def get_position_by_raw_index(self, index):
        """Get Position object at corresponding raw numeric position"""
        return list(self.positions.keys())[index]

    def find_human_germlines(self, limit=10, v_gene=None, j_gene=None, unique=True) -> Tuple[List['Chain'], List['Chain']]:
        """Find most identical V and J germline sequences based on IMGT alignment

        :param limit: Number of best matching germlines to return
        :param v_gene: Filter germlines to specific V gene name
        :param j_gene: Filter germlines to specific J gene name
        :param unique: Skip germlines with duplicate amino acid sequence
        :return: list of top V chains, list of top J chains
        """
        from abnumber.germlines import get_imgt_v_chains, get_imgt_j_chains

        chain = self if self.scheme == 'imgt' and self.cdr_definition == 'imgt' else self.renumber('imgt')
        v_chains = list(get_imgt_v_chains(chain.chain_type).values())
        j_chains = list(get_imgt_j_chains(chain.chain_type).values())

        if v_gene:
            if v_gene.startswith('IGKV') and self.chain_type == 'L':
                raise NotImplementedError('Cannot graft lambda chain into kappa chain')
            if v_gene.startswith('IGLV') and self.chain_type == 'K':
                raise NotImplementedError('Cannot graft kappa chain into lambda chain')
            v_chains = [chain for chain in v_chains if chain.name.startswith(v_gene)]
            if not v_chains:
                print('Available V genes:', get_imgt_v_chains(chain.chain_type).keys())
                raise ValueError(f'No V genes found for "{chain.chain_type}" chain gene name "{v_gene}"')

        if j_gene:
            j_chains = [chain for chain in j_chains if chain.name.startswith(j_gene)]
            if not j_chains:
                print('Available J genes:', get_imgt_j_chains(chain.chain_type).keys())
                raise ValueError(f'No J genes found for "{chain.chain_type}" chain gene name "{j_gene}"')
            
        if unique:
            v_chains = _get_unique_chains(v_chains)
            j_chains = _get_unique_chains(j_chains)

        v_alignments = [chain.align(germline) for germline in v_chains]
        v_ranks = np.array([alignment.num_mutations() for alignment in v_alignments]).argsort(kind='stable')[:limit]
        top_v_chains = [v_chains[r] for r in v_ranks]

        j_alignments = [chain.align(germline) for germline in j_chains]
        j_ranks = np.array([alignment.num_mutations() for alignment in j_alignments]).argsort(kind='stable')[:limit]
        top_j_chains = [j_chains[r] for r in j_ranks]

        return top_v_chains, top_j_chains

    def find_merged_human_germline(self, top=0, v_gene=None, j_gene=None) -> 'Chain':
        """Find n-th most identical V and J germline sequence based on IMGT alignment and merge them into one Chain

        :param top: Return top N most identical germline (0-indexed)
        :param v_gene: Filter germlines to specific V gene name
        :param j_gene: Filter germlines to specific J gene name
        :return: merged germline sequence Chain object
        """
        v_chains, j_chains = self.find_human_germlines(limit=top+1, v_gene=v_gene, j_gene=j_gene)
        v_chain = v_chains[top]
        j_chain = j_chains[top]

        merged_dict = {
            **{pos: aa for pos, aa in j_chain},
            **{pos: aa for pos, aa in v_chain}
        }

        return Chain(
            sequence=None,
            aa_dict=merged_dict,
            chain_type=self.chain_type,
            scheme='imgt',
            tail=''
        )

    @property
    def raw(self):
        """Access raw representation of this chain to allow unaligned numeric indexing and slicing

        >>> # String numbering is based on schema numbering
        >>> chain['1']
        'QVQLQQSGAE'
        >>> # Numbering of ``chain.raw`` starts at 0
        >>> chain.raw[0]
        'QVQLQQSGAE'
        >>> # Slicing with string is based on schema numbering, the end is inclusive
        >>> chain['1':'10']
        'QVQLQQSGAE'
        >>> # Slicing with ``chain.raw`` starts at 0, the end is exclusive (Python style)
        >>> chain.raw[0:10]
        'QVQLQQSGAE'

        :return: Raw chain accessor that can be sliced or indexed to produce a new :class:`Chain` object
        """
        return RawChainAccessor(self)

    @property
    def regions(self):
        """Dictionary of region dictionaries

        Region is an uppercase string, one of: ``"FR1", "CDR1", "FR2", "CDR2", "FR3", "CDR3", "FR4"``

        :return: Dictionary of Region name -> Dictionary of (:class:`Position` -> Amino acid)
        """
        return OrderedDict(
            FR1=self.fr1_dict,
            CDR1=self.cdr1_dict,
            FR2=self.fr2_dict,
            CDR2=self.cdr2_dict,
            FR3=self.fr3_dict,
            CDR3=self.cdr3_dict,
            FR4=self.fr4_dict
        )

    @property
    def positions(self):
        """Dictionary of :class:`Position` -> Amino acid"""
        positions = OrderedDict()
        for region, aa_dict in self.regions.items():
            for pos, aa in aa_dict.items():
                positions[pos] = aa
        return positions

    @property
    def seq(self):
        """Unaligned string representation of the variable chain sequence

        :return: Unaligned string representation of the variable chain sequence
        """
        return ''.join(self.positions.values())

    @property
    def fr1_seq(self):
        """Unaligned string representation of the Framework 1 region sequence"""
        return ''.join(self.fr1_dict.values())

    @property
    def cdr1_seq(self):
        """Unaligned string representation of the CDR 1 region sequence"""
        return ''.join(self.cdr1_dict.values())

    @property
    def fr2_seq(self):
        """Unaligned string representation of the Framework 2 region sequence"""
        return ''.join(self.fr2_dict.values())

    @property
    def cdr2_seq(self):
        """Unaligned string representation of the CDR 2 region sequence"""
        return ''.join(self.cdr2_dict.values())

    @property
    def fr3_seq(self):
        """Unaligned string representation of the Framework 3 region sequence"""
        return ''.join(self.fr3_dict.values())

    @property
    def cdr3_seq(self):
        """Unaligned string representation of the CDR 3 region sequence"""
        return ''.join(self.cdr3_dict.values())

    @property
    def fr4_seq(self):
        """Unaligned string representation of the Framework 4 region sequence"""
        return ''.join(self.fr4_dict.values())


class RawChainAccessor:
    def __init__(self, chain: Chain):
        self.chain = chain

    def __getitem__(self, item):
        if isinstance(item, slice):
            if item.step is not None and item.step != 1:
                raise IndexError(f'Slicing with step != 1 is not implemented, got: {item}')
            if item.start is not None and not is_integer(item.start):
                raise IndexError(f'Expected int start index for chain.raw, got {type(item.start)}: {item.start}')
            if item.stop is not None and not is_integer(item.stop):
                raise IndexError(f'Expected int end index for chain.raw, got {type(item.stop)}: {item.stop}')
            return self.chain.slice(start=item.start, stop=item.stop, stop_inclusive=False, allow_raw=True)
        if not is_integer(item):
            raise IndexError(f'Expected int indexing for chain.raw, got {type(item)}: {item}')
        pos = self.chain.get_position_by_raw_index(item)
        return self.chain[pos]