Skip to content

Clinical analytics

Analytics packages combine domain-neutral retrieval with governed concept sets and clinical interpretation. This is where a procedure becomes evidence of radiotherapy, a series of measurements becomes a weight trajectory, or percentage weight loss becomes a severity grade.

Oncology

API Capability
OncologyEpisode Classifies episode purpose and modality; traverses events linked to the episode and its direct children.
structural_modalities / concept_modalities Preserve every evidenced modality so mixed-treatment and SACT classification disagreements remain visible.
structural_modality / concept_modality Select one deterministic modality in radiotherapy, surgery, diagnostic/staging, SACT priority order.
OncologyProcedure / OncologyDrugExposure Add governed is_radiotherapy, is_surgery, is_diagnostic_staging, and is_sact questions to CDM facts.
RTDoseSummary.from_procedures(...) Constructs one radiotherapy summary; summarize_rt_procedures_by(...) groups before construction.
SACTDoseSummary.from_exposures(...) Constructs one SACT summary; summarize_sact_exposures_by(...) groups before construction.
OncologyEpisodeEvent Resolves oncology-aware facts while retaining episode-event diagnostics.

OncologyEpisode is the main entry point for an episode-centred oncology analysis. Keep the object attached to its SQLAlchemy session while accessing properties that traverse related events or resolve vocabulary-backed concept groups:

from sqlalchemy.orm import Session

from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode

with Session(engine) as session:
    episode = session.get(OncologyEpisode, episode_id)
    if episode is None:
        raise LookupError(f"Unknown episode: {episode_id}")

    treatment_episodes = episode.child_treatment_episodes
    modalities = episode.structural_modalities
    sact = episode.sact_dose_summaries_by_drug_concept
    radiotherapy = episode.rt_dose_summaries_by_site

The episode includes events linked directly to it and events linked to its direct children. This supports a regimen whose drug exposures or procedures are recorded against cycle-level child episodes without flattening the episode hierarchy itself.

flowchart TD OE["OncologyEpisode"] --> Direct["Events linked directly to this episode"] OE --> Children["child_treatment_episodes"] Children --> ChildEvents["Events linked to those children
(e.g. cycle-level drug exposures)"] Direct --> Pool["Evidence pool for
modalities, dose summaries, weight loss"] ChildEvents --> Pool

Modality evidence

An episode can contain evidence for more than one treatment modality. structural_modalities and concept_modalities therefore return sets rather than forcing the record into a single label:

  • structural evidence treats any linked drug exposure as SACT evidence and uses governed concepts for radiotherapy, surgery, and diagnostic or staging procedures;
  • concept evidence requires drug exposures to belong to the governed SACT concept set as well.

Comparing the two sets makes source-structure and terminology disagreements visible:

structural = episode.structural_modalities
governed = episode.concept_modalities

if structural != governed:
    review_episode_modality(episode.episode_id, structural, governed)

When a caller needs one value, structural_modality and concept_modality apply a deterministic order: radiotherapy, surgery, diagnostic or staging, then SACT. This is a stable tie-break for mixed evidence, not a statement of clinical importance. Use the plural properties when mixed treatment matters to the analysis.

flowchart LR RT["Radiotherapy"] --> SUR["Surgery"] --> DX["Diagnostic / Staging"] --> SACT["SACT"]

The single-value properties return the first modality in this order for which the episode has evidence.

Treatment summaries

sact_exposures contains linked exposures whose concepts belong to the governed SACT set. sact_dose_summaries_by_drug_concept groups them by drug concept; sact_dose_summary provides an all-SACT summary. The summary keeps source units and carries a DoseEvaluability result. Mixed units or missing quantities remain visible instead of being presented as a valid combined dose.

rt_procedures applies the governed radiotherapy procedure set. Site-grouped and whole-episode summaries expose dates, procedure and modifier concepts, counts, quantities, and dose evaluability. OMOP Procedure Occurrence does not provide a universal radiotherapy dose model, so these summaries preserve the available evidence for a site-specific policy rather than inferring one.

OncologyProcedure and OncologyDrugExposure expose the same governed classifications on individual facts. OncologyEpisodeEvent retains resolution diagnostics when a linked event cannot be loaded.

Condition modifiers and preferred stage

The oncology package publishes lazy governed concept specifications for T, N, M, and group stage, tumour grade, and metastatic disease. Laterality and tumour size are exposed as governed scalar accessors. These declarations consume omop-semantics; the narrow metastatic-disease descendant group requires omop-semantics 0.6.1. Importing the module does not expand a vocabulary or contact a database.

Stage selection is a query policy over an already filtered canonical modifier source enriched with modifier_concept_code. By default, pathological codes (trimmed, case-insensitive codes beginning with p) rank before clinical codes (beginning with c), with unclassified codes retained as fallback. Time and canonical modifier identity then break ties deterministically.

from omop_alchemy.toolkit.analytics.oncology import preferred_stage_select

# Earliest pathological, otherwise earliest clinical, otherwise unclassified.
preferred = preferred_stage_select(
    stage_modifiers,
    concept_code_column="modifier_concept_code",
)

The preference is immutable and query-scoped. Override it explicitly rather than changing process-global state:

from omop_alchemy.toolkit.analytics.oncology import StageSelectionSpec
from omop_alchemy.toolkit.core.modifiers import ModifierSelectionPolicy

clinical_first = preferred_stage_select(
    stage_modifiers,
    spec=StageSelectionSpec.clinical_first(),
    concept_code_column="modifier_concept_code",
)

chronological = preferred_stage_select(
    stage_modifiers,
    spec=StageSelectionSpec.chronological_only(),
)

latest_pathological = preferred_stage_select(
    stage_modifiers,
    spec=StageSelectionSpec(
        temporal_policy=ModifierSelectionPolicy.latest,
    ),
    concept_code_column="modifier_concept_code",
)

Basis-ranked selection requires an enriched source and an explicit concept_code_column=. Chronological-only selection does not require the enrichment. An empty basis priority disables pathological/clinical preference; a non-empty priority must contain every StageBasis exactly once.

Oncology policies for condition modifiers and preferred stage values.

StageSelectionSpec dataclass

StageSelectionSpec(
    basis_priority: tuple[StageBasis, ...] = (
        StageBasis.pathological,
        StageBasis.clinical,
        StageBasis.unclassified,
    ),
    temporal_policy: ModifierSelectionPolicy = ModifierSelectionPolicy.earliest,
)

Preferred stage basis followed by temporal tie-breaking policy.

The default prefers pathological stage, then clinical stage, then values whose vocabulary code does not identify a basis. Supply an empty basis_priority for chronological-only selection.

preferred_stage_select

preferred_stage_select(
    source: FromClause | SelectBase,
    *,
    spec: StageSelectionSpec = DEFAULT_STAGE_SELECTION,
    concept_code_column: str | None = None,
) -> sa.Select[Any]

Select one preferred stage modifier for every canonical target.

Basis-ranked selection requires a source enriched with a concept-code column. Chronological-only selection does not require that enrichment.

stage_basis_expression

stage_basis_expression(
    concept_code: ColumnElement[Any],
) -> sa.ColumnElement[str]

Classify stage codes using the source vocabulary's p/c convention.

The staging vocabulary does not expose a reliable parent concept that separates pathological from clinical staging. The code prefix is therefore the intentional source-level contract rather than a synthetic hierarchy.

stage_basis_priority_expression

stage_basis_priority_expression(
    concept_code: ColumnElement[Any],
    spec: StageSelectionSpec = DEFAULT_STAGE_SELECTION,
) -> sa.ColumnElement[int]

Render the configured stage-basis preference as a sortable SQL CASE.

Bases: OncologyCriticalWeightLossMixin, OncologySACTDosingMixin, OncologyRTDosingMixin, OncologyEpisodeEventMixin, EpisodeView

Oncology-aware episode view.

This composes generic episode hierarchy support, oncology-aware Episode_Event resolution, body-metric adverse-event grading, and treatment dose-summary interfaces. Modality classification exposes both structural treatment evidence, such as linked drug exposures, and governed concept evidence, such as SACT-classified drug concepts, so callers can audit disagreements.

primary_episode property

primary_episode: Self

child_treatment_episodes cached property

child_treatment_episodes: list[Self]

structural_modalities cached property

structural_modalities: frozenset[OncologyModality]

All modalities supported by linked event structure.

Any linked drug exposure is treated as structural SACT evidence, while radiotherapy, surgery, and diagnostic/staging require governed procedure concept membership. Events linked to direct child episodes are included.

structural_modality cached property

structural_modality: OncologyModality

Highest-priority structural modality, or UNKNOWN when none apply.

Priority is radiotherapy, surgery, diagnostic/staging, then SACT.

concept_modalities cached property

concept_modalities: frozenset[OncologyModality]

All modalities supported by linked procedure and drug concept identity.

This is intentionally distinct from structural_modalities so SACT disagreements remain visible.

concept_modality cached property

concept_modality: OncologyModality

Highest-priority concept modality, or UNKNOWN when none apply.

Priority is radiotherapy, surgery, diagnostic/staging, then SACT.

child_treatment_episodes_by_modality cached property

child_treatment_episodes_by_modality: dict[
    OncologyModality, list[Self]
]

child_treatment_episodes_by_concept_modality cached property

child_treatment_episodes_by_concept_modality: dict[
    OncologyModality, list[Self]
]

is_disease_episode

is_disease_episode() -> bool

is_overarching

is_overarching() -> bool

is_treatment_episode

is_treatment_episode() -> bool

is_treatment_regimen

is_treatment_regimen() -> bool

is_treatment_cycle

is_treatment_cycle() -> bool

Radiotherapy procedure summary for one caller-chosen site/group key.

OMOP procedure rows do not provide one universal RT dose model. This summary exposes dates, procedure concepts, modifiers, counts, and evaluability so a site-specific RT policy can decide what is clinically meaningful.

from_procedures classmethod

from_procedures(
    procedures: Sequence[OncologyProcedure],
    *,
    group_key: object,
) -> Self

Summarize radiotherapy procedure rows for one grouping key.

Bases: DrugExposureSummary

SACT dose summary for one caller-chosen drug grouping.

This is deliberately a summary interface, not a dose-reduction rule. It preserves mixed/missing units as evaluability states for downstream SACT policy to interpret.

from_exposures classmethod

from_exposures(
    exposures: Sequence[Drug_Exposure], *, group_key: object
) -> Self

Summarize SACT exposures and attach dose evaluability policy.

Construction is field-based and therefore accepts the base OMOP exposure type. Oncology filtering belongs to sact_exposures before this summary boundary; keeping the inherited input type also preserves substitutability.

Body metrics

API Capability
MeasurementReading.from_measurement(...) Reduces an OMOP measurement to the fields used by calculations and records its resolution source.
MeasurementSeriesMixin Resolves normalised measurement series for an episode.
WeightTrajectoryMixin Exposes normalised weight and height, BMI, BSA, windowed change, trajectories, and a dict-shaped typed summary.
WeightChange Represents percentage change and whether it was evaluable; unevaluable change has pct_change=None.
WeightTrajectorySummary Types the DataFrame- and JSON-friendly mapping returned by weight_trajectory_summary().

WeightTrajectoryMixin turns an episode's weight measurements and the person's height measurements into a normalised longitudinal view. Weight is converted to kilograms, height to centimetres, and measurements with missing or unrecognised units are excluded from calculations.

An episode that includes the mixin can produce a compact, tabular summary:

summary = episode.weight_trajectory_summary()

print(summary["baseline_weight_kg"])
print(summary["latest_weight_kg"])
print(summary["pct_change_from_baseline"])
print(summary["pct_change_from_baseline_evaluable"])

The baseline is the first normalised weight in the resolved episode series and the latest weight is the last. Percentage change is negative for weight loss. A result separates its value from evaluability so that missing evidence is not confused with zero change.

pct_change_over(days) compares the latest reading with the earliest reading inside the requested look-back period. pct_change_trajectory() returns every normalised point relative to baseline. sustained_loss() asks whether the final consecutive readings all meet a configurable loss threshold. These are deliberately distinct questions; choose the one that matches the analysis rather than treating them as interchangeable summaries of weight loss.

Body-metric defaults resolve governed measurement and unit concepts. A deployment that uses local concepts can supply its own BodyMetricRules on the episode class.

A resolved numeric measurement reduced to the fields trajectory math needs.

from_measurement classmethod

from_measurement(
    measurement: Measurement, *, source: ReadingSource
) -> Self

Reduce an OMOP measurement row to trajectory input fields.

Episode mixin exposing normalized weight, height, BMI, and trajectories.

Subclasses may set _body_metric_rules to avoid loading the default omop-semantics-backed concept IDs.

weight_readings cached property

weight_readings: list[MeasurementReading]

Weight readings normalized to kg.

height_readings_cm cached property

height_readings_cm: list[MeasurementReading]

Height readings normalized to cm and resolved without an episode date window.

height_m property

height_m: Optional[float]

Return the first normalized height as metres, when available.

baseline_weight property

baseline_weight: Optional[MeasurementReading]

Return the earliest normalized weight reading in the series.

latest_weight property

latest_weight: Optional[MeasurementReading]

Return the latest normalized weight reading in the series.

baseline_bmi property

baseline_bmi: Optional[float]

Calculate BMI from the baseline weight and first available height.

baseline_bsa_mosteller_m2 property

baseline_bsa_mosteller_m2: Optional[float]

Calculate Mosteller BSA from baseline weight and first height.

pct_change_from_baseline

pct_change_from_baseline(
    as_of: Optional[MeasurementReading] = None,
) -> WeightChange

Compare a target reading with baseline, preserving non-evaluability.

pct_change_over

pct_change_over(days: int) -> WeightChange

Compare latest weight with the earliest reading in the time window.

pct_change_trajectory

pct_change_trajectory() -> list[WeightTrajectoryPoint]

Return each valid reading's percentage change from baseline.

sustained_loss

sustained_loss(
    threshold_pct: float = 5.0, min_consecutive: int = 2
) -> Optional[bool]

Test whether recent readings sustain the requested loss threshold.

weight_trajectory_summary

weight_trajectory_summary() -> WeightTrajectorySummary

Return the stable summary contract used by analytics consumers.

Adverse events

API Policy
ctcae_weight_loss_grade(...) Grades percentage weight loss against CTCAE-style bins.
martin_weight_loss_grade(...) Applies the Martin et al. BMI-adjusted matrix.
critical_weight_loss_grade(...) Uses the Martin matrix when BMI is available and otherwise falls back to CTCAE-style bins.

The adverse-event functions apply grading policy to an already calculated percentage change and, where available, BMI:

from omop_alchemy.toolkit.analytics.adverse_events import (
    critical_weight_loss_grade,
)

grade = critical_weight_loss_grade(
    pct_change=-8.2,
    bmi=21.4,
)

martin_weight_loss_grade() applies the BMI-adjusted Martin et al. matrix. ctcae_weight_loss_grade() applies the CTCAE v5.0 physiological percentage-loss thresholds but does not infer intervention qualifiers such as hospitalisation, tube feeding, or parenteral nutrition. critical_weight_loss_grade() uses the Martin matrix when both percentage change and BMI are available and otherwise falls back to the percentage-only CTCAE-style grade.

All three return None when the inputs needed by that policy are unavailable. They do not retrieve measurements or choose a baseline; those decisions remain in the body-metric layer.

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.

MartinWeightLoss

Martin et al. BMI-adjusted percent-weight-loss grading.

Source: Martin L, Senesse P, Gioulbasanis I, et al. "Diagnostic criteria for the classification of cancer-associated weight loss." J Clin Oncol. 2015;33(1):90-99. The published system crosses five percent-weight-loss categories with five BMI categories to give grades 0-4.

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

critical_weight_loss_grade

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.