Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ temp/
*.bak
notebooks/
.dockerignore
docker/
docker/
tests/test_meds*
12 changes: 8 additions & 4 deletions omop_alchemy/cdm/base/modifier_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from sqlalchemy.ext.hybrid import hybrid_property
from typing import ClassVar,Optional
from datetime import date
from sqlalchemy.sql.elements import SQLColumnExpression

class ModifierTargetMixin:
"""
Expand All @@ -10,6 +11,7 @@ class ModifierTargetMixin:
"""

__abstract__ = True
__tablename__: ClassVar[str]
__event_id_col__: ClassVar[str]
__concept_id_col__: ClassVar[str]
__start_date_col__: ClassVar[str]
Expand All @@ -22,16 +24,18 @@ def modifier_field_concept_id(cls) -> int:

@classmethod
def modifier_target_table(cls) -> str:
return cls.__tablename__ # ty: ignore[unresolved-attribute]
return cls.__tablename__

@hybrid_property
def event_id(self) -> int:
return getattr(self, self.__event_id_col__)

@event_id.expression
def event_id(cls):

@event_id.inplace.expression
@classmethod
def _event_id(cls) -> SQLColumnExpression[int]:
return getattr(cls, cls.__event_id_col__)

@property
def concept_id(self) -> int:
return getattr(self, self.__concept_id_col__)
Expand Down
22 changes: 22 additions & 0 deletions omop_alchemy/cdm/handlers/_semantics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from typing import Any


def default_semantics_runtime() -> Any:
"""
Return the bundled omop-semantics runtime, or fail with install guidance.

omop-semantics is an optional dependency of omop-alchemy. Handler modules
that use governed default concept sets should call this lazily when those
defaults are actually needed, so importing core CDM models does not require
the semantics package.
"""
try:
from omop_semantics.runtime.default_valuesets import runtime
except ImportError as exc:
raise ImportError(
"This handler requires omop-semantics. Install omop-alchemy with "
"the 'semantics' extra, for example: omop-alchemy[semantics]."
) from exc
return runtime
14 changes: 14 additions & 0 deletions omop_alchemy/cdm/handlers/adverse_events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from .ctcae import CTCAEWeightLoss, ctcae_weight_loss_grade
from .weight_loss import (
MartinWeightLoss,
critical_weight_loss_grade,
martin_weight_loss_grade,
)

__all__ = [
"CTCAEWeightLoss",
"MartinWeightLoss",
"critical_weight_loss_grade",
"ctcae_weight_loss_grade",
"martin_weight_loss_grade",
]
27 changes: 27 additions & 0 deletions omop_alchemy/cdm/handlers/adverse_events/ctcae.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from typing import ClassVar, Optional


class CTCAEWeightLoss:
"""
CTCAE-style weight-loss severity from percent weight change only.

This implements physiological percent-loss bins.

CTCAE intervention qualifiers such as hospitalisation, tube feeding, or TPN
are not inferred here.
"""

THRESHOLDS: ClassVar[tuple[float, ...]] = (5.0, 10.0, 20.0)

@classmethod
def grade(cls, pct_change: Optional[float]) -> Optional[int]:
if pct_change is None:
return None
loss_pct = max(0.0, -pct_change)
return sum(1 for threshold in cls.THRESHOLDS if loss_pct >= threshold)


def ctcae_weight_loss_grade(pct_change: Optional[float]) -> Optional[int]:
return CTCAEWeightLoss.grade(pct_change)
78 changes: 78 additions & 0 deletions omop_alchemy/cdm/handlers/adverse_events/weight_loss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from __future__ import annotations

from typing import ClassVar, Optional

from .ctcae import ctcae_weight_loss_grade


class MartinWeightLoss:
"""
Martin et al. BMI-adjusted percent-weight-loss grading.

This matrix is kept in adverse events because it is clinical
severity policy over body measurements, not body-measurement
arithmetic itself.
"""

BMI_CATEGORY_BOUNDARIES: ClassVar[tuple[float, ...]] = (20.0, 22.0, 25.0, 28.0)
WEIGHT_LOSS_CATEGORY_BOUNDARIES: ClassVar[tuple[float, ...]] = (
2.5,
6.0,
11.0,
15.0,
)
GRADE_MATRIX: ClassVar[tuple[tuple[int, int, int, int, int], ...]] = (
(0, 0, 1, 1, 3),
(1, 2, 2, 2, 3),
(2, 3, 3, 3, 4),
(3, 3, 4, 4, 4),
(3, 4, 4, 4, 4),
)

@classmethod
def _bmi_category_index(cls, bmi: float) -> int:
for index, boundary in enumerate(reversed(cls.BMI_CATEGORY_BOUNDARIES)):
if bmi >= boundary:
return index
return len(cls.BMI_CATEGORY_BOUNDARIES)

@classmethod
def _weight_loss_category_index(cls, loss_pct: float) -> int:
for index, boundary in enumerate(cls.WEIGHT_LOSS_CATEGORY_BOUNDARIES):
if loss_pct < boundary:
return index
return len(cls.WEIGHT_LOSS_CATEGORY_BOUNDARIES)

@classmethod
def grade(
cls,
pct_change: Optional[float],
bmi: Optional[float],
) -> Optional[int]:
if pct_change is None or bmi is None:
return None
loss_pct = max(0.0, -pct_change)
return cls.GRADE_MATRIX[
cls._weight_loss_category_index(loss_pct)
][cls._bmi_category_index(bmi)]


def martin_weight_loss_grade(
pct_change: Optional[float],
bmi: Optional[float],
) -> Optional[int]:
return MartinWeightLoss.grade(pct_change, bmi)


def critical_weight_loss_grade(
pct_change: Optional[float],
bmi: Optional[float],
) -> Optional[int]:
"""
Critical-weight-loss grade using Martin where BMI is available.

Falls back to CTCAE-style percent-weight-loss grading when percent change is
evaluable but BMI is not, preserving coverage without guessing BMI.
"""
grade = martin_weight_loss_grade(pct_change, bmi)
return grade if grade is not None else ctcae_weight_loss_grade(pct_change)
54 changes: 54 additions & 0 deletions omop_alchemy/cdm/handlers/body_metrics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from .calculators import BodyMetricRules, default_body_metric_rules
from .concept_sets import (
BodySizeMeasurementConcepts,
BodySizeUnitConcepts,
default_body_size_measurement_concepts,
default_body_size_unit_concepts,
)
from .measurement_series import (
MeasurementReading,
MeasurementSeriesMixin,
ReadingSource,
episode_attachment_window,
reading_from_measurement,
resolve_measurement_series,
resolve_person_measurement_series,
)
from .units import (
INCH_TO_CM,
LB_US_TO_KG,
BodyUnitConversionRules,
default_body_unit_conversion_rules,
)
from .weight_trajectory import (
WeightChange,
WeightTrajectoryMixin,
WeightTrajectoryPoint,
normalize_height_readings,
normalize_weight_readings,
)

__all__ = [
"BodyMetricRules",
"BodySizeMeasurementConcepts",
"BodySizeUnitConcepts",
"BodyUnitConversionRules",
"INCH_TO_CM",
"LB_US_TO_KG",
"MeasurementReading",
"MeasurementSeriesMixin",
"ReadingSource",
"WeightChange",
"WeightTrajectoryMixin",
"WeightTrajectoryPoint",
"default_body_metric_rules",
"default_body_size_measurement_concepts",
"default_body_size_unit_concepts",
"default_body_unit_conversion_rules",
"episode_attachment_window",
"normalize_height_readings",
"normalize_weight_readings",
"reading_from_measurement",
"resolve_measurement_series",
"resolve_person_measurement_series",
]
76 changes: 76 additions & 0 deletions omop_alchemy/cdm/handlers/body_metrics/calculators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import math
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional

from .concept_sets import (
BodySizeMeasurementConcepts,
BodySizeUnitConcepts,
default_body_size_measurement_concepts,
)
from .units import BodyUnitConversionRules, default_body_unit_conversion_rules


@dataclass(frozen=True)
class BodyMetricRules:
"""
Anthropometric unit normalisation and body-size formula rules.
"""

measurements: BodySizeMeasurementConcepts
unit_rules: BodyUnitConversionRules

@classmethod
def default(cls) -> "BodyMetricRules":
return default_body_metric_rules()

@property
def units(self) -> BodySizeUnitConcepts:
return self.unit_rules.units

@property
def weight_concept_id(self) -> int:
return self.measurements.weight

@property
def height_concept_id(self) -> int:
return self.measurements.height

def normalize_weight_kg(
self,
value: Optional[float],
unit_concept_id: Optional[int],
) -> Optional[float]:
return self.unit_rules.normalize_weight_kg(value, unit_concept_id)

def normalize_height_cm(
self,
value: Optional[float],
unit_concept_id: Optional[int],
) -> Optional[float]:
return self.unit_rules.normalize_height_cm(value, unit_concept_id)

@staticmethod
def bmi(weight_kg: Optional[float], height_m: Optional[float]) -> Optional[float]:
if not weight_kg or not height_m:
return None
return weight_kg / (height_m**2)

@staticmethod
def bsa_mosteller_m2(
weight_kg: Optional[float],
height_cm: Optional[float],
) -> Optional[float]:
if not weight_kg or not height_cm:
return None
return math.sqrt((height_cm * weight_kg) / 3600.0)


@lru_cache(maxsize=1)
def default_body_metric_rules() -> BodyMetricRules:
return BodyMetricRules(
measurements=default_body_size_measurement_concepts(),
unit_rules=default_body_unit_conversion_rules(),
)
41 changes: 41 additions & 0 deletions omop_alchemy/cdm/handlers/body_metrics/concept_sets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from dataclasses import dataclass

from omop_alchemy.cdm.handlers._semantics import default_semantics_runtime


@dataclass(frozen=True)
class BodySizeMeasurementConcepts:
weight: int
height: int


@dataclass(frozen=True)
class BodySizeUnitConcepts:
kg: int
lb: int
cm: int
inch: int
m2: int


def default_body_size_measurement_concepts() -> BodySizeMeasurementConcepts:
runtime = default_semantics_runtime()
measurements = runtime.measurements_numeric.body_size_measurements
return BodySizeMeasurementConcepts(
weight=measurements.weight,
height=measurements.height,
)


def default_body_size_unit_concepts() -> BodySizeUnitConcepts:
runtime = default_semantics_runtime()
units = runtime.measurements_numeric.body_size_units
return BodySizeUnitConcepts(
kg=units.kg,
lb=units.lb,
cm=units.cm,
inch=units.inch,
m2=units.m2,
)
Loading
Loading