Skip to content

Commit ef3eb8c

Browse files
committed
feat: add support for class based score ranges
- Add a property `class_` to score calibration functional classifications. One of `range` or `class_` must be defined - Add validation logic to class based score ranges - Refactor lib code to support both range types - Refactor tests to support both range types TODO: Support for creating variant associations in class based score ranges.
1 parent a47ec9b commit ef3eb8c

23 files changed

+1225
-424
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""rename functional ranges to functional classifications, add class_ to model, rename classification to functional_classification
2+
3+
Revision ID: 0520dfa9f2db
4+
Revises: c770fa9e6e58
5+
Create Date: 2025-11-18 18:51:33.107952
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "0520dfa9f2db"
15+
down_revision = "c770fa9e6e58"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.alter_column(
23+
"score_calibration_functional_classifications",
24+
"classification",
25+
new_column_name="functional_classification",
26+
type_=sa.Enum(
27+
"normal", "abnormal", "not_specified", name="functionalclassification", native_enum=False, length=32
28+
),
29+
nullable=False,
30+
)
31+
op.add_column("score_calibration_functional_classifications", sa.Column("class_", sa.String(), nullable=True))
32+
# ### end Alembic commands ###
33+
34+
35+
def downgrade():
36+
# ### commands auto generated by Alembic - please adjust! ###
37+
op.alter_column(
38+
"score_calibration_functional_classifications",
39+
"functional_classification",
40+
new_column_name="classification",
41+
type_=sa.VARCHAR(length=32),
42+
nullable=False,
43+
)
44+
op.drop_column("score_calibration_functional_classifications", "class_")
45+
# ### end Alembic commands ###

src/mavedb/lib/annotation/classification.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from ga4gh.va_spec.acmg_2015 import VariantPathogenicityEvidenceLine
66
from ga4gh.va_spec.base.enums import StrengthOfEvidenceProvided
77

8-
from mavedb.models.enums.functional_classification import FunctionalClassification
8+
from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassificationOptions
99
from mavedb.models.mapped_variant import MappedVariant
10-
from mavedb.view_models.score_calibration import FunctionalRange
10+
from mavedb.view_models.score_calibration import FunctionalClassification
1111

1212
logger = logging.getLogger(__name__)
1313

@@ -44,7 +44,7 @@ def functional_classification_of_variant(
4444
" Unable to classify functional impact."
4545
)
4646

47-
if not primary_calibration.functional_ranges:
47+
if not primary_calibration.functional_classifications:
4848
raise ValueError(
4949
f"Variant {mapped_variant.variant.urn} does not have ranges defined in its primary score calibration."
5050
" Unable to classify functional impact."
@@ -58,14 +58,14 @@ def functional_classification_of_variant(
5858
" Unable to classify functional impact."
5959
)
6060

61-
for functional_range in primary_calibration.functional_ranges:
61+
for functional_range in primary_calibration.functional_classifications:
6262
# It's easier to reason with the view model objects for functional ranges than the JSONB fields in the raw database object.
63-
functional_range_view = FunctionalRange.model_validate(functional_range)
63+
functional_range_view = FunctionalClassification.model_validate(functional_range)
6464

6565
if functional_range_view.is_contained_by_range(functional_score):
66-
if functional_range_view.classification is FunctionalClassification.normal:
66+
if functional_range_view.functional_classification is FunctionalClassificationOptions.normal:
6767
return ExperimentalVariantFunctionalImpactClassification.NORMAL
68-
elif functional_range_view.classification is FunctionalClassification.abnormal:
68+
elif functional_range_view.functional_classification is FunctionalClassificationOptions.abnormal:
6969
return ExperimentalVariantFunctionalImpactClassification.ABNORMAL
7070
else:
7171
return ExperimentalVariantFunctionalImpactClassification.INDETERMINATE
@@ -97,7 +97,7 @@ def pathogenicity_classification_of_variant(
9797
" Unable to classify clinical impact."
9898
)
9999

100-
if not primary_calibration.functional_ranges:
100+
if not primary_calibration.functional_classifications:
101101
raise ValueError(
102102
f"Variant {mapped_variant.variant.urn} does not have ranges defined in its primary score calibration."
103103
" Unable to classify clinical impact."
@@ -111,9 +111,9 @@ def pathogenicity_classification_of_variant(
111111
" Unable to classify clinical impact."
112112
)
113113

114-
for pathogenicity_range in primary_calibration.functional_ranges:
114+
for pathogenicity_range in primary_calibration.functional_classifications:
115115
# It's easier to reason with the view model objects for functional ranges than the JSONB fields in the raw database object.
116-
pathogenicity_range_view = FunctionalRange.model_validate(pathogenicity_range)
116+
pathogenicity_range_view = FunctionalClassification.model_validate(pathogenicity_range)
117117

118118
if pathogenicity_range_view.is_contained_by_range(functional_score):
119119
if pathogenicity_range_view.acmg_classification is None:
@@ -124,7 +124,7 @@ def pathogenicity_classification_of_variant(
124124
if (
125125
pathogenicity_range_view.acmg_classification.evidence_strength is None
126126
or pathogenicity_range_view.acmg_classification.criterion is None
127-
): # pragma: no cover - enforced by model validators in FunctionalRange view model
127+
): # pragma: no cover - enforced by model validators in FunctionalClassification view model
128128
return (VariantPathogenicityEvidenceLine.Criterion.PS3, None)
129129

130130
# TODO#540: Handle moderate+
@@ -140,7 +140,7 @@ def pathogenicity_classification_of_variant(
140140
if (
141141
pathogenicity_range_view.acmg_classification.criterion.name
142142
not in VariantPathogenicityEvidenceLine.Criterion._member_names_
143-
): # pragma: no cover - enforced by model validators in FunctionalRange view model
143+
): # pragma: no cover - enforced by model validators in FunctionalClassification view model
144144
raise ValueError(
145145
f"Variant {mapped_variant.variant.urn} is contained in a clinical calibration range with an invalid criterion."
146146
" Unable to classify clinical impact."

src/mavedb/lib/annotation/util.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
from typing import Literal
2+
23
from ga4gh.core.models import Extension
34
from ga4gh.vrs.models import (
4-
MolecularVariation,
55
Allele,
66
CisPhasedBlock,
7-
SequenceLocation,
8-
SequenceReference,
97
Expression,
108
LiteralSequenceExpression,
9+
MolecularVariation,
10+
SequenceLocation,
11+
SequenceReference,
1112
)
12-
from mavedb.models.mapped_variant import MappedVariant
13+
1314
from mavedb.lib.annotation.exceptions import MappingDataDoesntExistException
15+
from mavedb.models.mapped_variant import MappedVariant
1416
from mavedb.view_models.score_calibration import SavedScoreCalibration
1517

1618

@@ -190,13 +192,16 @@ def _variant_score_calibrations_have_required_calibrations_and_ranges_for_annota
190192
saved_calibration = SavedScoreCalibration.model_validate(primary_calibration)
191193
if annotation_type == "pathogenicity":
192194
return (
193-
saved_calibration.functional_ranges is not None
194-
and len(saved_calibration.functional_ranges) > 0
195-
and any(fr.acmg_classification is not None for fr in saved_calibration.functional_ranges)
195+
saved_calibration.functional_classifications is not None
196+
and len(saved_calibration.functional_classifications) > 0
197+
and any(fr.acmg_classification is not None for fr in saved_calibration.functional_classifications)
196198
)
197199

198200
if annotation_type == "functional":
199-
return saved_calibration.functional_ranges is not None and len(saved_calibration.functional_ranges) > 0
201+
return (
202+
saved_calibration.functional_classifications is not None
203+
and len(saved_calibration.functional_classifications) > 0
204+
)
200205

201206
return True
202207

src/mavedb/lib/score_calibrations.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222
def create_functional_classification(
2323
db: Session,
24-
functional_range_create: Union[score_calibration.FunctionalRangeCreate, score_calibration.FunctionalRangeModify],
24+
functional_range_create: Union[
25+
score_calibration.FunctionalClassificationCreate, score_calibration.FunctionalClassificationModify
26+
],
2527
containing_calibration: ScoreCalibration,
2628
) -> ScoreCalibrationFunctionalClassification:
2729
"""
@@ -32,7 +34,7 @@ def create_functional_classification(
3234
3335
Args:
3436
db (Session): Database session for performing database operations.
35-
functional_range_create (score_calibration.FunctionalRangeCreate):
37+
functional_range_create (score_calibration.FunctionalClassificationCreate):
3638
Input data containing the functional range parameters including label,
3739
description, range bounds, inclusivity flags, and optional ACMG
3840
classification information.
@@ -64,7 +66,7 @@ def create_functional_classification(
6466
inclusive_lower_bound=functional_range_create.inclusive_lower_bound,
6567
inclusive_upper_bound=functional_range_create.inclusive_upper_bound,
6668
acmg_classification=acmg_classification,
67-
classification=functional_range_create.classification,
69+
functional_classification=functional_range_create.functional_classification,
6870
oddspaths_ratio=functional_range_create.oddspaths_ratio, # type: ignore[arg-type]
6971
positive_likelihood_ratio=functional_range_create.positive_likelihood_ratio, # type: ignore[arg-type]
7072
acmg_classification_id=acmg_classification.id if acmg_classification else None,
@@ -155,25 +157,25 @@ async def _create_score_calibration(
155157
**calibration_create.model_dump(
156158
by_alias=False,
157159
exclude={
158-
"functional_ranges",
160+
"functional_classifications",
159161
"threshold_sources",
160162
"classification_sources",
161163
"method_sources",
162164
"score_set_urn",
163165
},
164166
),
165167
publication_identifier_associations=calibration_pub_assocs,
166-
functional_ranges=[],
168+
functional_classifications=[],
167169
created_by=user,
168170
modified_by=user,
169171
) # type: ignore[call-arg]
170172

171-
for functional_range_create in calibration_create.functional_ranges or []:
173+
for functional_range_create in calibration_create.functional_classifications or []:
172174
persisted_functional_range = create_functional_classification(
173175
db, functional_range_create, containing_calibration=calibration
174176
)
175177
db.add(persisted_functional_range)
176-
calibration.functional_ranges.append(persisted_functional_range)
178+
calibration.functional_classifications.append(persisted_functional_range)
177179

178180
return calibration
179181

@@ -406,15 +408,15 @@ async def modify_score_calibration(
406408
# Remove associations and calibrations that are no longer present
407409
for assoc in existing_assocs_map.values():
408410
db.delete(assoc)
409-
for functional_classification in calibration.functional_ranges:
411+
for functional_classification in calibration.functional_classifications:
410412
db.delete(functional_classification)
411-
calibration.functional_ranges.clear()
413+
calibration.functional_classifications.clear()
412414
db.flush()
413415
db.refresh(calibration)
414416

415417
for attr, value in calibration_update.model_dump().items():
416418
if attr not in {
417-
"functional_ranges",
419+
"functional_classifications",
418420
"threshold_sources",
419421
"classification_sources",
420422
"method_sources",
@@ -430,12 +432,12 @@ async def modify_score_calibration(
430432
calibration.publication_identifier_associations = updated_assocs
431433
calibration.modified_by = user
432434

433-
for functional_range_update in calibration_update.functional_ranges or []:
435+
for functional_range_update in calibration_update.functional_classifications or []:
434436
persisted_functional_range = create_functional_classification(
435437
db, functional_range_update, containing_calibration=calibration
436438
)
437439
db.add(persisted_functional_range)
438-
calibration.functional_ranges.append(persisted_functional_range)
440+
calibration.functional_classifications.append(persisted_functional_range)
439441

440442
db.add(calibration)
441443
return calibration

src/mavedb/models/score_calibration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class ScoreCalibration(Base):
4141
baseline_score = Column(Float, nullable=True)
4242
baseline_score_description = Column(String, nullable=True)
4343

44-
functional_ranges: Mapped[list["ScoreCalibrationFunctionalClassification"]] = relationship(
44+
functional_classifications: Mapped[list["ScoreCalibrationFunctionalClassification"]] = relationship(
4545
"ScoreCalibrationFunctionalClassification",
4646
back_populates="calibration",
4747
cascade="all, delete-orphan",

src/mavedb/models/score_calibration_functional_classification.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mavedb.db.base import Base
1212
from mavedb.lib.validation.utilities import inf_or_float
1313
from mavedb.models.acmg_classification import ACMGClassification
14-
from mavedb.models.enums.functional_classification import FunctionalClassification
14+
from mavedb.models.enums.functional_classification import FunctionalClassification as FunctionalClassificationOptions
1515
from mavedb.models.score_calibration_functional_classification_variant_association import (
1616
score_calibration_functional_classification_variants_association_table,
1717
)
@@ -32,13 +32,15 @@ class ScoreCalibrationFunctionalClassification(Base):
3232
label = Column(String, nullable=False)
3333
description = Column(String, nullable=True)
3434

35-
classification = Column(
36-
Enum(FunctionalClassification, native_enum=False, validate_strings=True, length=32),
35+
functional_classification = Column(
36+
Enum(FunctionalClassificationOptions, native_enum=False, validate_strings=True, length=32),
3737
nullable=False,
38-
default=FunctionalClassification.not_specified,
38+
default=FunctionalClassificationOptions.not_specified,
3939
)
4040

4141
range = Column(JSONB(none_as_null=True), nullable=True) # (lower_bound, upper_bound)
42+
class_ = Column(String, nullable=True)
43+
4244
inclusive_lower_bound = Column(Boolean, nullable=True, default=True)
4345
inclusive_upper_bound = Column(Boolean, nullable=True, default=False)
4446

src/mavedb/scripts/load_calibration_csv.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
from mavedb.scripts.environment import with_database_session
107107
from mavedb.view_models.acmg_classification import ACMGClassificationCreate
108108
from mavedb.view_models.publication_identifier import PublicationIdentifierCreate
109-
from mavedb.view_models.score_calibration import FunctionalRangeCreate, ScoreCalibrationCreate
109+
from mavedb.view_models.score_calibration import FunctionalClassificationCreate, ScoreCalibrationCreate
110110

111111
BRNICH_PMID = "31892348"
112112
RANGE_PATTERN = re.compile(r"^\s*([\[(])\s*([^,]+)\s*,\s*([^\])]+)\s*([])])\s*$", re.IGNORECASE)
@@ -274,7 +274,7 @@ def build_ranges(row: Dict[str, str], infer_strengths: bool = True) -> Tuple[Lis
274274

275275
label = row.get(f"class_{i}_name", "").strip()
276276
ranges.append(
277-
FunctionalRangeCreate(
277+
FunctionalClassificationCreate(
278278
label=label,
279279
classification=classification,
280280
range=(lower, upper),
@@ -366,7 +366,7 @@ def main(db: Session, csv_path: str, delimiter: str, overwrite: bool, purge_publ
366366
method_sources=method_publications,
367367
classification_sources=calculation_publications,
368368
research_use_only=False,
369-
functional_ranges=ranges,
369+
functional_classifications=ranges,
370370
notes=calibration_notes,
371371
)
372372
except Exception as e: # broad to keep import running

src/mavedb/scripts/load_pp_style_calibration.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def main(db: Session, archive_path: str, dataset_map: str, overwrite: bool) -> N
183183
click.echo(f" Overwriting existing '{calibration_name}' in Score Set {score_set.urn}")
184184

185185
benign_has_lower_functional_scores = calibration_data.get("scoreset_flipped", False)
186-
functional_ranges: List[score_calibration.FunctionalRangeCreate] = []
186+
functional_classifications: List[score_calibration.FunctionalClassificationCreate] = []
187187
for points, range_data in calibration_data.get("point_ranges", {}).items():
188188
if not range_data:
189189
continue
@@ -212,7 +212,7 @@ def main(db: Session, archive_path: str, dataset_map: str, overwrite: bool) -> N
212212
inclusive_lower = False
213213
inclusive_upper = True if upper_bound is not None else False
214214

215-
functional_range = score_calibration.FunctionalRangeCreate(
215+
functional_range = score_calibration.FunctionalClassificationCreate(
216216
label=f"{ps_or_bs} {strength_label} ({points})",
217217
classification="abnormal" if points > 0 else "normal",
218218
range=range_data,
@@ -222,11 +222,11 @@ def main(db: Session, archive_path: str, dataset_map: str, overwrite: bool) -> N
222222
inclusive_lower_bound=inclusive_lower,
223223
inclusive_upper_bound=inclusive_upper,
224224
)
225-
functional_ranges.append(functional_range)
225+
functional_classifications.append(functional_range)
226226

227227
score_calibration_create = score_calibration.ScoreCalibrationCreate(
228228
title=calibration_name,
229-
functional_ranges=functional_ranges,
229+
functional_classifications=functional_classifications,
230230
research_use_only=True,
231231
score_set_urn=score_set.urn,
232232
calibration_metadata={"prior_probability_pathogenicity": calibration_data.get("prior", None)},

0 commit comments

Comments
 (0)