Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions src/ga4gh/va_spec/ccv_2022/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
"""Module to load and init namespace at package level."""

from .derived_evidence import (
CODE_PREFIX_TO_SCORE_MAP,
CODE_SUFFIX_TO_STRENGTH_MAP,
derive_onco_evidence_attributes,
)
from .derived_evidence import derive_onco_evidence_attributes
from .models import (
METHOD,
SYSTEM,
Expand All @@ -13,8 +9,6 @@
)

__all__ = [
"CODE_PREFIX_TO_SCORE_MAP",
"CODE_SUFFIX_TO_STRENGTH_MAP",
"derive_onco_evidence_attributes",
"METHOD",
"SYSTEM",
Expand Down
137 changes: 96 additions & 41 deletions src/ga4gh/va_spec/ccv_2022/derived_evidence.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,48 @@
"""Provide derived evidence attributes for an onco evidence code.
"""Derive evidence line attributes from a CCV 2022 evidence outcome code.

Can be used to populate `evidenceOutcome`, `strengthOfEvidenceProvided`, and
`scoreOfEvidenceProvided` fields in `VariantOncogenicityEvidenceLine`.
Can be used to populate `evidenceOutcome`, `directionOfEvidenceProvided`,
`strengthOfEvidenceProvided`, and `scoreOfEvidenceProvided` fields in
`VariantOncogenicityEvidenceLine`.
"""

import re
from types import MappingProxyType
from typing import NamedTuple

from pydantic import BaseModel

from ga4gh.core.models import Coding, MappableConcept, code
from ga4gh.va_spec.base.core import Method
from ga4gh.va_spec.base.core import Direction, Method
from ga4gh.va_spec.base.enums import StrengthOfEvidenceProvided, System
from ga4gh.va_spec.ccv_2022.models import (
METHOD as CCV_METHOD,
CCV_CODE_PATTERN,
VariantOncogenicityEvidenceLine,
)
from ga4gh.va_spec.ccv_2022.models import (
VariantOncogenicityEvidenceLine,
METHOD as CCV_METHOD,
)
Comment thread
korikuzma marked this conversation as resolved.


class EvidenceAttributes(BaseModel):
"""Define derived evidence attributes for an onco evidence code."""
"""Store the evidence line attributes derived from a CCV outcome code."""

evidenceOutcome: MappableConcept
strengthOfEvidenceProvided: MappableConcept
scoreOfEvidenceProvided: int
directionOfEvidenceProvided: Direction
strengthOfEvidenceProvided: MappableConcept | None
scoreOfEvidenceProvided: int | None
specifiedBy: Method


class _ParsedEvidenceOutcome(NamedTuple):
"""Store the normalized parts of a CCV evidence outcome code."""

outcome: str
criterion: VariantOncogenicityEvidenceLine.Criterion
modifier: str


# IMPORTANT: Don't change the order. Longer suffixes must be evaluated first.
CODE_SUFFIX_TO_STRENGTH_MAP = MappingProxyType(
_CODE_SUFFIX_TO_DEFAULT_STRENGTH_MAP = MappingProxyType(
{
"VS": StrengthOfEvidenceProvided.VERY_STRONG,
"S": StrengthOfEvidenceProvided.STRONG,
Expand All @@ -39,59 +52,101 @@ class EvidenceAttributes(BaseModel):
)


CODE_PREFIX_TO_SCORE_MAP = MappingProxyType(
_STRENGTH_TO_SCORE_MAGNITUDE_MAP = MappingProxyType(
{
"OVS": 8,
"SBVS": -8,
"OS": 4,
"SBS": -4,
"OM": 2,
"SBM": -2,
"OP": 1,
"SBP": -1,
StrengthOfEvidenceProvided.VERY_STRONG: 8,
StrengthOfEvidenceProvided.STRONG: 4,
StrengthOfEvidenceProvided.MODERATE: 2,
StrengthOfEvidenceProvided.SUPPORTING: 1,
}
)


def _parse_ccv_evidence_outcome(
evidence: VariantOncogenicityEvidenceLine.Criterion | str,
) -> _ParsedEvidenceOutcome:
"""Normalize and validate a CCV evidence outcome code.

:param evidence: A base criterion or complete CCV evidence outcome code.
:raises ValueError: If the outcome code does not follow the CCV format.
:return: The canonical outcome code and its parsed parts.
"""
provided_outcome = (
evidence.value
if isinstance(evidence, VariantOncogenicityEvidenceLine.Criterion)
else evidence
)
evidence_code, separator, outcome_modifier = provided_outcome.partition("_")
outcome_modifier = outcome_modifier.lower()
evidence_outcome = (
f"{evidence_code}_{outcome_modifier}" if separator else evidence_code
)

if re.fullmatch(CCV_CODE_PATTERN, evidence_outcome) is None:
msg = f"Invalid CCV evidence outcome: {provided_outcome}"
raise ValueError(msg)

criterion = VariantOncogenicityEvidenceLine.Criterion(evidence_code)
return _ParsedEvidenceOutcome(evidence_outcome, criterion, outcome_modifier)


def derive_onco_evidence_attributes(
evidence: VariantOncogenicityEvidenceLine.Criterion,
evidence: VariantOncogenicityEvidenceLine.Criterion | str,
) -> EvidenceAttributes:
"""Derive evidence attributes given a CCV 2022 evidence code.
"""Derive evidence line attributes from a CCV 2022 outcome code.

:param evidence: CCV 2022 evidence code
:return: Derived evidence attributes (evidenceOutcome, strengthOfEvidenceProvided,
scoreOfEvidenceProvided, specifiedBy)
:param evidence: A base criterion or complete CCV evidence outcome code.
:raises ValueError: If the outcome code does not follow the CCV format.
:return: The attributes needed to populate a CCV evidence line.
"""
Comment thread
larrybabb marked this conversation as resolved.
evidence_code = evidence.value
normalized_evidence_code = evidence_code.rstrip("1234")

code_suffix = next(
suffix
for suffix in CODE_SUFFIX_TO_STRENGTH_MAP
if normalized_evidence_code.endswith(suffix)
parsed_outcome = _parse_ccv_evidence_outcome(evidence)
evidence_code = parsed_outcome.criterion.value
criterion_prefix = evidence_code.rstrip("1234")

default_strength = next(
default_strength
for suffix, default_strength in _CODE_SUFFIX_TO_DEFAULT_STRENGTH_MAP.items()
if criterion_prefix.endswith(suffix)
)
code_prefix = next(
prefix
for prefix in CODE_PREFIX_TO_SCORE_MAP
if normalized_evidence_code.startswith(prefix)
direction, score_sign = (
(Direction.DISPUTES, -1)
if evidence_code.startswith("SB")
else (Direction.SUPPORTS, 1)
)

if parsed_outcome.modifier == "not_met":
applied_strength = None
direction = Direction.NEUTRAL
else:
applied_strength = (
StrengthOfEvidenceProvided(parsed_outcome.modifier.replace("_", " "))
if parsed_outcome.modifier
else default_strength
)
system = System.CCV

return EvidenceAttributes(
evidenceOutcome=MappableConcept(
primaryCoding=Coding(code=code(evidence_code), system=system)
primaryCoding=Coding(code=code(parsed_outcome.outcome), system=system)
),
strengthOfEvidenceProvided=MappableConcept(
primaryCoding=Coding(
code=code(CODE_SUFFIX_TO_STRENGTH_MAP[code_suffix]), system=system
directionOfEvidenceProvided=direction,
strengthOfEvidenceProvided=(
MappableConcept(
primaryCoding=Coding(code=code(applied_strength), system=system)
)
if applied_strength is not None
else None
),
scoreOfEvidenceProvided=(
score_sign * _STRENGTH_TO_SCORE_MAGNITUDE_MAP[applied_strength]
if applied_strength is not None
else None
),
scoreOfEvidenceProvided=CODE_PREFIX_TO_SCORE_MAP[code_prefix],
specifiedBy=CCV_METHOD.model_copy(
deep=True,
update={
"methodType": VariantOncogenicityEvidenceLine.METHOD_TYPE_BY_CRITERION[
evidence
parsed_outcome.criterion
].value
},
),
Expand Down
11 changes: 9 additions & 2 deletions src/ga4gh/va_spec/ccv_2022/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@
)

SYSTEM = System.CCV
CCV_CODE_PATTERN = (
r"^("
r"(?:OVS1|SBVS1)(?:_(?:not_met|(?:strong|moderate|supporting)))?"
r"|(?:OS[1-3]|SBS[1-2])(?:_(?:not_met|(?:very_strong|moderate|supporting)))?"
r"|(?:OM[1-4])(?:_(?:not_met|(?:very_strong|strong|supporting)))?"
r"|(OP[1-4]|SBP[1-2])(?:_(?:not_met|very_strong|strong|moderate))?"
r")$"
)
METHOD = Method( # recommended representation of ClinGen/CGC/VICC 2022 method
name=SYSTEM,
reportedIn=Document(
Expand Down Expand Up @@ -226,8 +234,7 @@ def validate_model(self) -> Self:
``directionOfEvidenceProvided`` is neutral
"""
self._validate_direction_of_evidence_provided()
ccv_code_pattern = r"^((?:OVS1|SBVS1)(?:_(?:not_met|(?:strong|moderate|supporting)))?|(?:OS[1-3]|SBS[1-2])(?:_(?:not_met|(?:very_strong|moderate|supporting)))?|(?:OM[1-4])(?:_(?:not_met|(?:very_strong|strong|supporting)))?|(OP[1-4]|SBP[1-2])(?:_(?:not_met|very_strong|strong|moderate))?)$"
self._validate_evidence_outcome(SYSTEM, ccv_code_pattern, is_required=True)
self._validate_evidence_outcome(SYSTEM, CCV_CODE_PATTERN, is_required=True)
self._validate_criterion_specified_by()
self._validate_method_type_evidence_outcome(
self.specifiedBy.methodType, self.evidenceOutcome.primaryCoding.code.root
Expand Down
78 changes: 78 additions & 0 deletions tests/test_ccv_derived_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,82 @@ def test_derive_onco_evidence_attributes(
== expected_strength
)
assert onco_evidence_attrs.scoreOfEvidenceProvided == expected_score
expected_direction = "disputes" if expected_score < 0 else "supports"
assert onco_evidence_attrs.directionOfEvidenceProvided == expected_direction
assert onco_evidence_attrs.specifiedBy.methodType == expected_method_type.value


@pytest.mark.parametrize(
("outcome", "expected_direction", "expected_score", "expected_method_type"),
[
(
"OS2_moderate",
"supports",
2,
VariantOncogenicityEvidenceLine.MethodType.FUNCTIONAL_ASSAY,
),
(
"SBS2_moderate",
"disputes",
-2,
VariantOncogenicityEvidenceLine.MethodType.FUNCTIONAL_ASSAY,
),
],
)
def test_derive_onco_evidence_attributes_with_adjusted_strength(
outcome, expected_direction, expected_score, expected_method_type
):
"""Test that the outcome's adjusted strength determines strength and score."""
onco_evidence_attrs = derive_onco_evidence_attributes(outcome)

evidence_outcome = onco_evidence_attrs.evidenceOutcome.primaryCoding.code.root
assert evidence_outcome == outcome
evidence_line = VariantOncogenicityEvidenceLine(**onco_evidence_attrs.model_dump())
assert evidence_line.evidenceOutcome.primaryCoding.code.root == evidence_outcome
assert onco_evidence_attrs.directionOfEvidenceProvided == expected_direction
assert (
onco_evidence_attrs.strengthOfEvidenceProvided.primaryCoding.code.root
== "moderate"
)
assert onco_evidence_attrs.scoreOfEvidenceProvided == expected_score
assert onco_evidence_attrs.specifiedBy.methodType == expected_method_type.value


@pytest.mark.parametrize(
("provided_outcome", "expected_outcome"),
[
("OS2_MODERATE", "OS2_moderate"),
("OS2_Moderate", "OS2_moderate"),
("OS2_NOT_MET", "OS2_not_met"),
],
)
def test_derive_onco_evidence_attributes_normalizes_modifier(
provided_outcome, expected_outcome
):
"""Test that outcome modifiers are normalized to lowercase."""
onco_evidence_attrs = derive_onco_evidence_attributes(provided_outcome)

assert (
onco_evidence_attrs.evidenceOutcome.primaryCoding.code.root == expected_outcome
)


def test_derive_onco_evidence_attributes_does_not_normalize_criterion():
"""Test that criterion codes must retain their canonical uppercase form."""
with pytest.raises(ValueError, match="Invalid CCV evidence outcome"):
derive_onco_evidence_attributes("os2_MODERATE")


def test_derive_onco_evidence_attributes_from_not_met_outcome():
"""Test that a not-met outcome has no strength or score."""
onco_evidence_attrs = derive_onco_evidence_attributes("OS2_not_met")

assert onco_evidence_attrs.evidenceOutcome.primaryCoding.code.root == "OS2_not_met"
assert onco_evidence_attrs.directionOfEvidenceProvided == "neutral"
assert onco_evidence_attrs.strengthOfEvidenceProvided is None
assert onco_evidence_attrs.scoreOfEvidenceProvided is None
assert (
onco_evidence_attrs.specifiedBy.methodType
== VariantOncogenicityEvidenceLine.MethodType.FUNCTIONAL_ASSAY.value
)
VariantOncogenicityEvidenceLine(**onco_evidence_attrs.model_dump())
Loading