Skip to content
Open
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ on:
jobs:
label-gate:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main
lint-imports:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v5
- run: uv run --frozen --extra dev lint-imports
build-test-sqlite:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main
with:
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,6 @@ temp/
*.bak
notebooks/
.dockerignore
docker/
docker/
tests/test_meds*
site/
12 changes: 12 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[importlinter]
root_package = omop_alchemy

[importlinter:contract:toolkit-layers]
name = Toolkit tiers are strictly layered, cdm is the schema they are built on
type = layers
layers =
omop_alchemy.toolkit.integrations
omop_alchemy.toolkit.analytics
omop_alchemy.toolkit.episodes
omop_alchemy.toolkit.core
omop_alchemy.cdm
20 changes: 10 additions & 10 deletions docs/advanced/timelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ production query pipelines where raw SQLAlchemy queries are more appropriate.
A canonical temporal representation. Every clinical event has a start datetime; an end
datetime is optional. The `kind` property returns `"point"` or `"interval"`.

::: omop_alchemy.cdm.handlers.timeline.event_timeline.EventTime
::: omop_alchemy.toolkit.core.timeline.event_timeline.EventTime

---

### `EventValue`

The value associated with a clinical event — numeric, concept, string, or none.

::: omop_alchemy.cdm.handlers.timeline.event_timeline.EventValue
::: omop_alchemy.toolkit.core.timeline.event_timeline.EventValue

---

Expand All @@ -33,7 +33,7 @@ Declares which ORM fields supply the concept, start/end datetimes, and value for
particular CDM table. Subclasses of `ClinicalEvent` set `_mapping` to an `EventMapping`
instance at class level.

::: omop_alchemy.cdm.handlers.timeline.event_timeline.EventMapping
::: omop_alchemy.toolkit.core.timeline.event_timeline.EventMapping

---

Expand All @@ -43,7 +43,7 @@ instance at class level.
`_mapping` to implement `event_time`, `event_value`, `event_metadata`, `to_dict`, and
`to_json`.

::: omop_alchemy.cdm.handlers.timeline.event_timeline.ClinicalEvent
::: omop_alchemy.toolkit.core.timeline.event_timeline.ClinicalEvent

---

Expand All @@ -57,11 +57,11 @@ Three CDM tables are pre-wired with `EventMapping`s:
| `Measurement_Event` | `measurement` | `measurement_concept_id` | `value_as_number`, `value_as_concept_id`, `value_as_string` |
| `Drug_Exposure_Event` | `drug_exposure` | `drug_concept_id` | `quantity` |

::: omop_alchemy.cdm.handlers.timeline.event_timeline.Condition_Event
::: omop_alchemy.toolkit.core.timeline.event_timeline.Condition_Event

::: omop_alchemy.cdm.handlers.timeline.event_timeline.Measurement_Event
::: omop_alchemy.toolkit.core.timeline.event_timeline.Measurement_Event

::: omop_alchemy.cdm.handlers.timeline.event_timeline.Drug_Exposure_Event
::: omop_alchemy.toolkit.core.timeline.event_timeline.Drug_Exposure_Event

---

Expand All @@ -71,15 +71,15 @@ Extends the `Person` ORM class with `.events` and `.timeline` properties. Requir
active SQLAlchemy session (i.e. the object must have been loaded from a session, not
constructed in memory).

::: omop_alchemy.cdm.handlers.timeline.event_timeline.Person_Timeline
::: omop_alchemy.toolkit.core.timeline.event_timeline.Person_Timeline

---

## Usage example

```python
from sqlalchemy.orm import Session
from omop_alchemy.cdm.handlers.timeline import Person_Timeline
from omop_alchemy.toolkit.core.timeline import Person_Timeline

with Session(engine) as session:
person = session.get(Person_Timeline, 42)
Expand All @@ -96,7 +96,7 @@ To add a new CDM table to the timeline, subclass both `ClinicalEvent` and the ta
class and set `_mapping`:

```python
from omop_alchemy.cdm.handlers.timeline.event_timeline import ClinicalEvent, EventMapping
from omop_alchemy.toolkit.core.timeline.event_timeline import ClinicalEvent, EventMapping
from omop_alchemy.cdm.model.clinical import Procedure_Occurrence

class Procedure_Event(Procedure_Occurrence, ClinicalEvent):
Expand Down
11 changes: 1 addition & 10 deletions docs/api/typing.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ utilities that operate across multiple CDM tables.

!!! note
The concrete mixin of the same name lives in
`omop_alchemy.cdm.handlers.timeline.event_timeline`. The Protocol here is the
`omop_alchemy.toolkit.core.timeline.event_timeline`. The Protocol here is the
structural interface; the mixin there is the implementation.

::: omop_alchemy.cdm.base.typing.ClinicalEvent
Expand All @@ -83,15 +83,6 @@ utilities that operate across multiple CDM tables.

---

### `ConceptResolver`

Protocol for objects that can look up whether a set of concept IDs are standard.

::: omop_alchemy.cdm.base.typing.ConceptResolver
options:
heading_level: 4
---

## Typed row containers (`cdm.model.typing`)

### `ConceptRow`
Expand Down
66 changes: 66 additions & 0 deletions docs/toolkit/analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# analytics

Clinical-domain logic, one subpackage per domain. Where `core` and `episodes` are
deliberately domain-agnostic, the subpackages here encode what a value *means* in a
particular clinical context: which concepts constitute a treatment, what counts as a
significant change, how severity is graded. Each domain owns its own concept sets, kept
beside the code that uses them, and domains may depend on each other where the clinical
logic genuinely composes.

## oncology

Cancer treatment and disease episodes. `OncologyEpisode` is the main entry point — it
classifies itself from its episode concepts and exposes the facts it contains:

```python
from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode

episode = session.get(OncologyEpisode, episode_id)
episode.structural_modality # OncologyModality.SACT, .RADIOTHERAPY, .SURGERY, ...
episode.is_treatment_cycle
episode.rt_dose_summaries_by_site
episode.sact_dose_summary
```

Episode traversal resolves to oncology-aware fact classes — `OncologyDrugExposure` and
`OncologyProcedure` extend their CDM counterparts with questions such as `is_sact` and
`is_radiotherapy` — and `OncologyEpisodeEvent` carries resolution diagnostics the same
way `episodes.handling`'s `ResolvedEpisodeEvent` does, since it extends it.

::: omop_alchemy.toolkit.analytics.oncology

## body_metrics

Weight, height, and BMI as measurement series and trajectories. `WeightTrajectoryMixin`
gives an episode view normalised weight and height, BMI, and windowed weight change:

```python
from omop_alchemy.toolkit.analytics.body_metrics import WeightTrajectoryMixin

class MyEpisode(WeightTrajectoryMixin, EpisodeView):
...

episode.baseline_bmi
episode.pct_change_over(days=180)
```

`WeightChange.pct_change` is `None` whenever the change is not evaluable — too few
readings, or an unusable unit — so callers cannot mistake an unknown for a zero.

::: omop_alchemy.toolkit.analytics.body_metrics

## adverse_events

Grades clinical severity against published criteria, kept separate from the
measurement code in `body_metrics` so the standard being applied is always explicit.

```python
from omop_alchemy.toolkit.analytics.adverse_events import critical_weight_loss_grade

grade = critical_weight_loss_grade(pct_change=-12.0, bmi=21.4)
```

`critical_weight_loss_grade` uses the Martin et al. BMI-adjusted matrix where BMI is
available and falls back to CTCAE-style percent-loss bins where it is not.

::: omop_alchemy.toolkit.analytics.adverse_events
40 changes: 40 additions & 0 deletions docs/toolkit/core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# core

Foundational services with no clinical-domain assumptions. A concept resolver behaves
the same whether it is mapping tumour morphology or procedures; a patient timeline is
the same object whatever populates it. Domain-specific concept sets, thresholds, and
grading rules belong in [`analytics`](analytics.md), not here.

## Concept resolution

Turns a declarative description of *which* concepts belong in a lookup into a runtime
resolver that maps free text and source codes to OMOP concept IDs.

```python
from omop_alchemy.toolkit.core.concepts import make_concept_resolver

resolver = make_concept_resolver(
session,
name="condition lookup",
domain_id="Condition",
)
concept_id = resolver.lookup("Adenocarcinoma of lung")
```

::: omop_alchemy.toolkit.core.concepts

## Patient timelines

Projects a person's clinical rows — conditions, measurements, drug exposures — into a
single time-ordered event stream. This has its own dedicated page, since it predates the
rest of the toolkit reorg: see [Patient Timelines](../advanced/timelines.md).

## Unit conversion

Converts measurement values to canonical units. Kilograms, pounds, centimetres, and
inches mean the same thing in every clinical domain, so the conversion rules live here
rather than with any one domain's measurement logic — see
[`analytics.body_metrics`](analytics.md#body_metrics) for where those domain-specific
measurements are resolved and normalised using these rules.

::: omop_alchemy.toolkit.core.units
63 changes: 63 additions & 0 deletions docs/toolkit/episodes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# episodes

Domain-neutral machinery for building episodes and retrieving what belongs to them. A
drug episode behaves the same whether the drug is a cytotoxic agent or an antibiotic, so
everything here takes concept filters and grouping keys as parameters rather than
assuming a clinical specialty. Domain-specific episode classes — for example
`OncologyEpisode` — compose these pieces with their own concept sets and live in
[`analytics`](analytics.md).

## derivation

How episodes are constructed and related to one another — building episode queries and
resolving parent/child hierarchy, written against the raw `Episode`/`Episode_Event`
tables rather than any materialised view.

Not yet populated. The equivalent built against materialised-view subclasses lives in
`omop-constructs`.

## handling

What is inside an episode once it exists.

**Linked drug exposures.** `DrugEpisodeMixin` adds retrieval and grouped summaries to
any episode view:

```python
from omop_alchemy.toolkit.episodes.handling import DrugEpisodeMixin

class MyEpisode(DrugEpisodeMixin, EpisodeView):
_drug_concept_ids = my_concept_ids

episode.drug_exposures # resolved Drug_Exposure rows
episode.drug_exposure_summaries_by() # grouped by drug concept by default
```

Dose quantities are frequently not comparable across agents, because source units and
quantities arrive unnormalised. `DoseEvaluability` carries that judgement alongside the
number, so a summary that cannot be interpreted as a dose says so rather than presenting
a misleading total.

**Explicit links versus admitted-by-window.** Facts linked through `Episode_Event` are
always honoured. `episode_attachment_window` computes the bounded, date-based fallback
window used when a caller opts in to admitting same-person facts that weren't explicitly
linked.

**Resolution diagnostics.** `Episode_EventView.resolved_event` already resolves an
`Episode_Event` link best-effort, returning `None` on failure. `ResolvedEpisodeEvent`
extends it to explain *why* — a miscoded field concept, a target class not yet
registered, or a genuinely dangling reference:

```python
from omop_alchemy.toolkit.episodes.handling import ResolvedEpisodeEvent

ee = session.get(ResolvedEpisodeEvent, (episode_id, event_id, field_concept_id))
ee.resolved_event # the resolved row, or None
ee.event_resolution_diagnostics # [] if resolved cleanly, otherwise why not
```

Mix `ResolvedEpisodeEventMixin` into an episode view to reach diagnostics through
ordinary `episode.episode_events` traversal instead of a direct query — this is how
`OncologyEpisode` gets diagnostics on oncology-aware event resolution for free.

::: omop_alchemy.toolkit.episodes.handling
55 changes: 55 additions & 0 deletions docs/toolkit/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Toolkit

`omop_alchemy.cdm` gives you the OMOP CDM schema as SQLAlchemy models. The toolkit is
what you build with them: vocabulary resolution, patient timelines, episode traversal,
domain analytics, and outbound export.

!!! warning "Experimental"
The toolkit is newer and less battle-tested than the CDM models it sits on top of.
Module paths below an area (anything past `toolkit.<tier>.<area>`) may still move;
the area itself is the stable import surface. The CDM models themselves are not
affected by anything here.

## Four tiers

Each tier may depend only on the tiers before it in this list — `core` knows nothing of
episodes or clinical domains, and nothing depends on `integrations`.

| Tier | Answers | Assumes a clinical domain? |
|---|---|---|
| [`core`](core.md) | Resolve concepts, build a patient timeline, convert units | No |
| [`episodes`](episodes.md) | Build episodes, retrieve what belongs to one | No |
| [`analytics`](analytics.md) | What does this value mean clinically? | Yes, one subpackage per domain |
| [`integrations`](integrations.md) | Export to an external data standard | No |

## A worked example

Querying an oncology episode pulls together all four tiers without you having to think
about the seams between them: concept resolution and timelines from `core`, drug
retrieval from `episodes`, oncology classification from `analytics`.

```python
from sqlalchemy.orm import Session
from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode

with Session(engine) as session:
episode = session.get(OncologyEpisode, episode_id)

episode.structural_modality # OncologyModality.SACT, .RADIOTHERAPY, ...
episode.drug_exposures # linked Drug_Exposure rows
episode.rt_dose_summary # RTDoseSummary, if any RT was given
episode.critical_weight_loss_grade # graded against Martin/CTCAE criteria
```

## Import surface

Import from the area subpackage — `omop_alchemy.toolkit.<tier>.<area>` — not from a
specific module beneath it:

```python
from omop_alchemy.toolkit.core.concepts import make_concept_resolver
from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode
```

Each area's `__init__.py` re-exports its public names, and that's the part of the path
that stays stable. Files beneath it are free to move.
14 changes: 14 additions & 0 deletions docs/toolkit/integrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# integrations

Export to external data standards. Integrations sit at the outer edge of the toolkit —
one may use anything in `core`, `episodes`, or `analytics`, and nothing in those tiers
depends on an integration, so adding or changing an export format cannot affect the
clinical logic beneath it. Each integration brings its own heavyweight dependencies and
is gated behind an optional extra, so installing omop-alchemy does not pull in formats
you are not exporting to.

## meds_standard

Export to the [Medical Event Data Standard](https://github.com/Medical-Event-Data-Standard/meds).

Not yet populated.
7 changes: 7 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ nav:
- Domain: models/vocabulary/domain.md
- Vocabulary: models/vocabulary/vocabulary.md

- Toolkit:
- Overview: toolkit/index.md
- core: toolkit/core.md
- episodes: toolkit/episodes.md
- analytics: toolkit/analytics.md
- integrations: toolkit/integrations.md

- OMOP-Specific Validation:
- Overview: validation/index.md
- Domain Rules: validation/domain-rules.md
Expand Down
Loading
Loading