Skip to content

Commit

Permalink
Merge pull request #1188 from mild-blue/1139-umet-si-poradit-i-se-spe…
Browse files Browse the repository at this point in the history
…sl-formatem-hla-type-pri-pocitani-crossmatche

Handle special HLAType format in crossmatch API
  • Loading branch information
kubantjan authored May 25, 2023
2 parents 4173421 + 829e478 commit cba7852
Show file tree
Hide file tree
Showing 14 changed files with 713 additions and 151 deletions.
329 changes: 325 additions & 4 deletions tests/web/test_do_crossmatch_api.py

Large diffs are not rendered by default.

48 changes: 32 additions & 16 deletions tests/web/test_matching_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,22 +94,28 @@ def test_get_matchings(self):
expected_score = [
{
'hla_group': HLAGroup.A.name,
'donor_matches': [{'hla_type': {'code': self._get_split('A11'), 'raw_code': 'A11'},
'donor_matches': [{'hla_type': {'code': self._get_split('A11'), 'raw_code': 'A11',
'display_code': 'A11'},
'match_type': MatchType.NONE.name},
{'hla_type': {'code': self._get_split('A11'), 'raw_code': 'A11'},
{'hla_type': {'code': self._get_split('A11'), 'raw_code': 'A11',
'display_code': 'A11'},
'match_type': MatchType.NONE.name}],
'recipient_matches': [{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3'},
'recipient_matches': [{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3',
'display_code': 'A3'},
'match_type': MatchType.NONE.name},
{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3'},
{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3',
'display_code': 'A3'},
'match_type': MatchType.NONE.name}],
'antibody_matches': expected_antibodies[0],
'group_compatibility_index': 0.0
},
{
'hla_group': HLAGroup.B.name,
'donor_matches': [{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8'},
'donor_matches': [{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8',
'display_code': 'B8'},
'match_type': MatchType.NONE.name},
{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8'},
{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8',
'display_code': 'B8'},
'match_type': MatchType.NONE.name}],
'recipient_matches': [{'hla_type': {'code': self._get_split('B7'), 'raw_code': 'B7'},
'match_type': MatchType.NONE.name},
Expand All @@ -120,9 +126,11 @@ def test_get_matchings(self):
},
{
'hla_group': HLAGroup.DRB1.name,
'donor_matches': [{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11'},
'donor_matches': [{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11',
'display_code': 'DR11'},
'match_type': MatchType.SPLIT.name},
{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11'},
{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11',
'display_code': 'DR11'},
'match_type': MatchType.SPLIT.name}],
'recipient_matches': [{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11'},
'match_type': MatchType.SPLIT.name},
Expand All @@ -135,22 +143,28 @@ def test_get_matchings(self):
expected_score2 = [
{
'hla_group': HLAGroup.A.name,
'donor_matches': [{'hla_type': {'code': self._get_split('A2'), 'raw_code': 'A2'},
'donor_matches': [{'hla_type': {'code': self._get_split('A2'), 'raw_code': 'A2',
'display_code': 'A2'},
'match_type': MatchType.NONE.name},
{'hla_type': {'code': self._get_split('A2'), 'raw_code': 'A2'},
{'hla_type': {'code': self._get_split('A2'), 'raw_code': 'A2',
'display_code': 'A2'},
'match_type': MatchType.NONE.name}],
'recipient_matches': [{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3'},
'recipient_matches': [{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3',
'display_code': 'A3'},
'match_type': MatchType.NONE.name},
{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3'},
{'hla_type': {'code': self._get_split('A3'), 'raw_code': 'A3',
'display_code': 'A3'},
'match_type': MatchType.NONE.name}],
'antibody_matches': expected_antibodies[0],
'group_compatibility_index': 0.0
},
{
'hla_group': HLAGroup.B.name,
'donor_matches': [{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8'},
'donor_matches': [{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8',
'display_code': 'B8'},
'match_type': MatchType.NONE.name},
{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8'},
{'hla_type': {'code': self._get_split('B8'), 'raw_code': 'B8',
'display_code': 'B8'},
'match_type': MatchType.NONE.name}],
'recipient_matches': [{'hla_type': {'code': self._get_split('B7'), 'raw_code': 'B7'},
'match_type': MatchType.NONE.name},
Expand All @@ -161,9 +175,11 @@ def test_get_matchings(self):
},
{
'hla_group': HLAGroup.DRB1.name,
'donor_matches': [{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11'},
'donor_matches': [{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11',
'display_code': 'DR11'},
'match_type': MatchType.SPLIT.name},
{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11'},
{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11',
'display_code': 'DR11'},
'match_type': MatchType.SPLIT.name}],
'recipient_matches': [{'hla_type': {'code': self._get_split('DR11'), 'raw_code': 'DR11'},
'match_type': MatchType.SPLIT.name},
Expand Down
3 changes: 3 additions & 0 deletions txmatching/data_transfer_objects/base_patient_swagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

ANTIGENS_EXAMPLE = ['A1', 'A32', 'B7', 'B51', 'DR11', 'DR15', 'A*02:03', 'A*11:01:35', 'DPA1*01:07', 'DRB4*01:01',
'DQB1*02:01:01:01']
ANTIGENS_AS_LISTS_SPECIAL_EXAMPLE = [['A*01:02', 'A*01:03', 'A*01:06'],
['A*02:02', 'A*02:04', 'A*02:05'],
['B*07:26', 'B*07:27']]

ANTIBODIES_EXAMPLE = ['A1', 'A32', 'B7', 'B51', 'DR11', 'DR15', 'A*02:03', 'A*11:01:35', 'DRB4*01:01',
'DP[02:01,02:01]', 'DQ[02:01,02:01]']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@

@dataclass
class CrossmatchDTOIn:
donor_hla_typing: List[str]
potential_donor_hla_typing: List[List[str]]
recipient_antibodies: List[HLAAntibodiesUploadDTO]

def get_maximum_donor_hla_typing(self):
return [hla_type for hla_typing in self.potential_donor_hla_typing
for hla_type in hla_typing]


@dataclass
class CrossmatchDTOOut:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from flask_restx import fields

from txmatching.data_transfer_objects.base_patient_swagger import (
ANTIGENS_EXAMPLE, HLA_TYPING_DESCRIPTION, HLAAntibodyJsonIn)
ANTIGENS_AS_LISTS_SPECIAL_EXAMPLE, HLA_TYPING_DESCRIPTION, HLAAntibodyJsonIn)
from txmatching.data_transfer_objects.hla.hla_swagger import HLAAntibody, HLAType, HLACode
from txmatching.data_transfer_objects.hla.parsing_issue_swagger import ParsingIssueBaseJson
from txmatching.data_transfer_objects.matchings.matching_swagger import AntibodyMatchJson
Expand All @@ -18,8 +18,12 @@
CrossmatchJsonIn = crossmatch_api.model(
'CrossmatchInput',
{
'donor_hla_typing': fields.List(required=True, cls_or_instance=fields.String,
example=ANTIGENS_EXAMPLE, description=HLA_TYPING_DESCRIPTION),
'potential_donor_hla_typing': fields.List(required=True,
cls_or_instance=fields.List(
required=True,
cls_or_instance=fields.String),
example=ANTIGENS_AS_LISTS_SPECIAL_EXAMPLE,
description=HLA_TYPING_DESCRIPTION),
'recipient_antibodies': fields.List(required=True,
description='Detected HLA antibodies of the patient. Use high resolution '
'if available. If high resolution is provided it is assumed '
Expand All @@ -32,8 +36,9 @@
)

AntibodyMatchForHLAType = crossmatch_api.model('AntibodyMatchForHLAType', {
'hla_type': fields.Nested(HLAType, required=True),
'assumed_hla_type': fields.List(required=True, cls_or_instance=fields.Nested(HLAType, required=True)),
'antibody_matches': fields.List(required=False, cls_or_instance=fields.Nested(AntibodyMatchJson)),
'summary_antibody': fields.Nested(AntibodyMatchJson, readonly=True)
})

CrossmatchJsonOut = crossmatch_api.model(
Expand Down
1 change: 1 addition & 0 deletions txmatching/data_transfer_objects/hla/hla_swagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
HLAType = patient_api.model('HlaType', {
'code': fields.Nested(HLACode, required=True),
'raw_code': fields.String(required=True),
'display_code': fields.String(required=False)
})

HLATypeRaw = patient_api.model('HlaTypeRaw', {
Expand Down
8 changes: 8 additions & 0 deletions txmatching/patients/hla_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ def display_code(self) -> str:
else:
raise AssertionError('This should never happen. At least one code should be specified.')

def get_low_res_code(self):
return self.split or self.broad

def is_in_high_res(self):
return self.high_res is not None

def _is_raw_code_in_group(self, hla_group: HLAGroup) -> bool:
if self.broad is not None:
return bool(re.match(HLA_GROUPS_PROPERTIES[hla_group].split_code_regex, self.broad))
Expand All @@ -43,6 +49,8 @@ def __hash__(self):
return hash((self.high_res, self.split, self.broad))

def __eq__(self, other):
if not isinstance(other, type(self)):
return False
if self.high_res and other.high_res:
return self.high_res == other.high_res
elif self.split and other.split:
Expand Down
18 changes: 18 additions & 0 deletions txmatching/patients/hla_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ class HLABase:

@dataclass
class HLAType(HLABase, PersistentlyHashable):
# We get this display_code attribute from the self.code: HLACode for the do-crossmatch endpoint, because
# this trick bypasses several problems associated with passing this attribute to the output of the endpoint.
# Try to use explicitly self.code.display_code (not self.display_code) in every possible place in the code.
display_code: Optional[str] = None

def __post_init__(self):
self.display_code = self.code.display_code

def __eq__(self, other):
"""
Needed for List[HLAType].remove()
Expand Down Expand Up @@ -132,6 +140,16 @@ class HLAAntibodies(PersistentlyHashable):
def hla_antibodies_per_groups_over_cutoff(self) -> List[AntibodiesPerGroup]:
return _filter_antibodies_per_groups_over_cutoff(self.hla_antibodies_per_groups)

def get_antibodies_codes_as_list(self) -> List[HLACode]:
hla_codes = []
for antibody_group in self.hla_antibodies_per_groups:
for antibody in antibody_group.hla_antibody_list:
if antibody.code:
hla_codes.append(antibody.code)
if antibody.second_code:
hla_codes.append(antibody.second_code)
return hla_codes

def update_persistent_hash(self, hash_: HashType):
update_persistent_hash(hash_, HLAAntibodies)
update_persistent_hash(hash_, self.hla_antibodies_per_groups)
Expand Down
89 changes: 87 additions & 2 deletions txmatching/utils/hla_system/hla_crossmatch.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass, field
from typing import Callable, List, Set
from typing import Callable, List, Set, Optional

from txmatching.auth.exceptions import InvalidArgumentException
from txmatching.patients.hla_code import HLACode
Expand Down Expand Up @@ -31,8 +31,93 @@ class AntibodyMatchForHLAGroup:

@dataclass
class AntibodyMatchForHLAType:
hla_type: HLAType
# If we have List[HLAType], which biologically carries the meaning of only one HLA Type
# (we simply cannot choose which one is the right one),
# then we call that object assumed_hla_type, and it has the following properties:
# - must not be empty
# - must have a uniform HLA code in low res, i.e we do not allow situation ['A*01:01', 'A*02:01']
# - must not have several codes in low res, i.e. we do not allow situation ['A1', 'A1']
assumed_hla_type: List[HLAType]
antibody_matches: List[AntibodyMatch] = field(default_factory=list)
summary_antibody: Optional[AntibodyMatch] = field(init=False) # antibody with the largest MFI value

def __init__(self, assumed_hla_type: List[HLAType],
antibody_matches: List[AntibodyMatch] = None):
self.__class__.validate_assumed_hla_type(assumed_hla_type)
self.assumed_hla_type = assumed_hla_type
self.antibody_matches = antibody_matches or []

@property
def summary_antibody(self) -> Optional[AntibodyMatch]:
return max(self.antibody_matches,
key=lambda match: match.hla_antibody.mfi) if self.antibody_matches else None

@classmethod
def from_crossmatched_antibodies(cls, assumed_hla_type: List[HLAType],
crossmatched_antibodies: List[AntibodyMatchForHLAGroup]):
"""
Generates an instance of the AntibodyMatchForHLAType according to the assumed HLA type
and possible pre-calculated crossmatched antibodies.
:param assumed_hla_type: special representation of the classic HLAType (see comment
at the begging of the dataclass AntibodyMatchForHLAType)
:param crossmatched_antibodies: antibodies that we know are likely to have a crossmatch
but are categorized into HLA groups.
:return: instance of this class.
"""
cls.validate_assumed_hla_type(assumed_hla_type)
antibody_matches = cls._find_common_matches(assumed_hla_type, crossmatched_antibodies)
return cls(assumed_hla_type, antibody_matches)

@classmethod
def validate_assumed_hla_type(cls, assumed_hla_type: List[HLAType]):
if not assumed_hla_type:
raise AttributeError("AntibodyMatchForHLAType needs at least one assumed hla_type.")
if cls._are_multiple_hlas_in_assumed(assumed_hla_type) and \
not cls._is_assumed_hla_type_in_high_res(assumed_hla_type):
raise ValueError("Multiple HLA codes in assumed HLA type are only accepted"
" in high resolution.")
if cls._is_assumed_hla_type_uniquely_defined_in_low_res(assumed_hla_type):
raise ValueError("Assumed HLA type must be uniquely defined in "
"split or broad resolution.")

@classmethod
def _find_common_matches(cls, assumed_hla_type: List[HLAType],
crossmatched_antibodies: List[AntibodyMatchForHLAGroup]) \
-> Optional[List[AntibodyMatch]]:
return [antibody_group_match for match_per_group in crossmatched_antibodies
for antibody_group_match in match_per_group.antibody_matches
if cls._is_assumed_hla_type_corresponds_antibody(assumed_hla_type,
antibody_group_match.hla_antibody)]

@classmethod
def _is_assumed_hla_type_in_high_res(cls, assumed_hla_type: List[HLAType]) -> bool:
for hla_type in assumed_hla_type:
if not hla_type.code.is_in_high_res():
return False
return True

@classmethod
def _are_multiple_hlas_in_assumed(cls, assumed_hla_type: List[HLAType]) -> bool:
return len(assumed_hla_type) > 1

@classmethod
def _is_assumed_hla_type_uniquely_defined_in_low_res(cls, assumed_hla_type: List[HLAType]) -> bool:
return len({hla_type.code.get_low_res_code() for hla_type in assumed_hla_type}) > 1

@classmethod
def _is_assumed_hla_type_corresponds_antibody(cls, assumed_hla_type: List[HLAType],
hla_antibody: HLAAntibody) -> bool:
for hla_type in assumed_hla_type:
if hla_type.code == hla_antibody.code or (hla_antibody.second_code and
hla_type.code == hla_antibody.second_code):
return True
return False

def __hash__(self):
return hash((tuple(self.assumed_hla_type), tuple(self.antibody_matches)))

def __eq__(self, other):
return hash(self) == hash(other)


def get_crossmatched_antibodies_per_group(donor_hla_typing: HLATyping,
Expand Down
7 changes: 5 additions & 2 deletions txmatching/utils/hla_system/hla_preparation_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
parse_hla_antibodies_raw_and_return_parsing_issue_list


def create_hla_typing(hla_types_list: List[str]) -> HLATyping:
def create_hla_typing(hla_types_list: List[str],
ignore_max_number_hla_types_per_group: bool = False) -> HLATyping:
raw_type_list = [HLATypeRaw(hla_type) for hla_type in hla_types_list]
typing_dto = HLATypingRawDTO(
hla_types_list=raw_type_list
)
parsed_typing_dto = parse_hla_typing_raw_and_return_parsing_issue_list(hla_typing_raw=typing_dto)[1]
parsed_typing_dto = parse_hla_typing_raw_and_return_parsing_issue_list(
hla_typing_raw=typing_dto,
ignore_max_number_hla_types=ignore_max_number_hla_types_per_group)[1]
return HLATyping(
hla_types_raw_list=raw_type_list,
hla_per_groups=parsed_typing_dto.hla_per_groups
Expand Down
Loading

0 comments on commit cba7852

Please sign in to comment.