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
7 changes: 7 additions & 0 deletions .devcontainer/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,10 @@ services:
volumes:
postgres-data:
pgadmin-data:

networks:
# Keep the shared development network independent of the Compose project
# name so sibling devcontainers can join it reliably.
default:
name: hemonc-alchemy_default
external: true
Comment thread
gkennos marked this conversation as resolved.
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"workspaceFolder": "/workspace/hemonc-alchemy",
"remoteUser": "vscode",
"updateRemoteUserUID": true,
"initializeCommand": "docker network create hemonc-alchemy_default || true",
"postCreateCommand": "bash .devcontainer/post-create.sh",
// also on start, so containers created before the kernel fix get it
"postStartCommand": "bash .devcontainer/register-kernel.sh",
Expand Down
4 changes: 4 additions & 0 deletions docs/getting-started/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ The repository's `.devcontainer` is a disposable environment for exploring the m
From the repository root:

```bash
docker network inspect hemonc-alchemy_default >/dev/null 2>&1 || \
docker network create hemonc-alchemy_default
docker compose -f .devcontainer/compose.yaml up -d
```

The network is shared with SCOOP and must exist before Compose starts.

Open the repository in VS Code's Dev Container. The project interpreter inside the Python service is `/opt/venv/bin/python`; the notebook kernel should use that environment.

## Inspect the database with pgAdmin
Expand Down
33 changes: 32 additions & 1 deletion docs/toolkit/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,41 @@ clinic = frame[frame.route_group == "IV"]
matrix = administration_matrix(frame)
```

The frame has one row per drug per explicit cycle day. `decay_days=0` gives dosing days only; the default decay adds an intensity tail to the following days for occupancy-style views. Passing a list of variants produces one combined frame, which should be grouped by `variant_cui`, not the human-readable `variant` label.
The frame has one row per drug per explicit cycle day. `decay_days=0` gives dosing days only; the default decay adds an intensity tail to the following days for occupancy-style views. Decay rows have `intensity < 1` and are not extra doses. Passing a list of variants produces one combined frame, which should be grouped by `variant_cui`, not the human-readable `variant` label.

In `administration_frame`, `indefinite` marks days continuing beyond the explicit days in `alldays`, while `cycle_indefinite` marks cycles continuing beyond the explicit cycles in `timing_sequence`. These columns preserve separate source markers; neither extends the returned rows.

`route_group == "IV"` is a historical shorthand for clinic-administered routes and includes more than intravenous administration. `"PO"` represents home-administered routes. Unrecognized or unspecified routes are excluded from administration projections rather than guessed at, so the frame may cover less than the source variant.

## Preserve source-shaped values

`ScheduleEvent` keeps fields such as dose and cycle-length bounds in their source form. A value like `"1.5-2"` needs an application decision before it becomes numeric. Generated enum fields remain enum members. Convert these values at the boundary where your application can state its handling of null, uncertain, or non-numeric source values.

## Roll out a complete variant timeline

`roll_out_variant` composes explicit cycle numbers, block-specific cycle lengths, and phase boundaries into a deterministic treatment timeline:

```python
from datetime import date

from hemonc_alchemy.toolkit.analytics.treatment.scheduling import roll_out_variant

timeline = roll_out_variant(variant)
timeline[["sig_id", "component", "phase", "cycle_number", "day", "phase_elapsed_day", "elapsed_day", "timing_status"]]
```

`elapsed_day` is an integer relative to variant start, where day 0 is the variant start. `phase_elapsed_day` is relative to the start of each phase, with day 0 at that phase's first explicit cycle. When a missing surgery or another unresolved boundary makes the variant-relative date unknown, `elapsed_day` and `calendar_date` stay null while `phase_elapsed_day` retains the within-phase timing where it can be calculated. An unresolved block within the phase also has a null `phase_elapsed_day`. Pass `start_date` to receive known `calendar_date` values as timezone-free `datetime.date` objects:

```python
timeline = roll_out_variant(variant, start_date=date(2026, 1, 1))
```

Blocks with adjacent cycle-number ranges are anchored sequentially. Blocks with overlapping cycle numbers share the relevant anchor, while genuinely ambiguous source timing remains visible in `timing_status`. When differing cycle lengths overlap across more than one shared cycle, the later block is unresolved. When blocks ending the preceding cycle differ in cycle length, the following block is unresolved. A phase can continue cycle numbering from its predecessor when its first cycle immediately follows the predecessor's last cycle. The status is `"resolved"`, `"resolved_via_fallback: ..."`, or `"unresolved: ..."`. Phases ordered by the documented fallback rule still chain from the previous phase's computed end when that end is known.

Unresolved phase ordering leaves every date in the variant null. A gap in explicit `phase_step` values leaves dates null from the later phase onward. Optional cycles are counted as given and marked `optional=True`; dates from the first optional cycle and every later phase carry a `resolved_via_fallback` status.

Each rollout row includes `sig_id` (the source `Sigs.id`), `timing_sequence`, `cycle_length_lb`, `cycle_length_ub`, `cycle_length_unit`, and the `cycle_length_selection` used for this rollout. These fields identify the sig and its cycle definition when a later consumer can supply a missing phase duration. With `start_date`, a phase containing calendar-month or calendar-year cycles has null phase-relative offsets if its actual start date is unknown, since the number of days depends on that date. Without `start_date`, the existing 30-day month and 365-day year approximations apply.

The timeline includes `modality` (`"systemic"`, `"radiation"`, or null for an unclassified sig), plus the source `component` and `component_cui`. Radiation rows can have a null `drug`. Pass `systemic_only=True` to omit radiation rows from the result; radiation phases still contribute to the timing of later phases. The timeline's `day_indefinite` and `cycle_indefinite` columns distinguish continuing days within a cycle from continuing cycles.

`administration_frame` retains its cycle-local `day` column and adds `elapsed_day` and `timing_status`. It combines sigs for the same drug and day, so use `roll_out_variant` when phase-relative timing or sig identity is needed. Variants without resolvable cycle metadata still produce explicitly resolvable administration rows, with a null `elapsed_day` and an unresolved status rather than an invented calendar position. Numeric `(+k)` means continuation every k cycles in `timing_sequence` or every k days in `alldays`; the interval is retained without sampling future events. A continuing cycle marker prevents dates from being chained into a later phase.
8 changes: 4 additions & 4 deletions hemonc_alchemy/toolkit/analytics/treatment/classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
RAD_SIG_CLASS_VALUE = Sigs_Class_fieldEnum.RAD_SIG


def _sig_class_value(sig_or_value):
def sig_class_value(sig_or_value):
value = getattr(sig_or_value, "class_field", sig_or_value)
if isinstance(value, Sigs_Class_fieldEnum):
return value
Expand All @@ -26,7 +26,7 @@ def _sig_class_value(sig_or_value):

def has_radiation_sig(variant) -> bool:
"""Whether any of a variant's component sigs are a radiation sig."""
return any(_sig_class_value(sig) == RAD_SIG_CLASS_VALUE for sig in variant.component_sigs)
return any(sig_class_value(sig) == RAD_SIG_CLASS_VALUE for sig in variant.component_sigs)


def has_non_radiation_sig(variant) -> bool:
Expand All @@ -37,14 +37,14 @@ def has_non_radiation_sig(variant) -> bool:
classification.
"""
return any(
(value := _sig_class_value(sig)) is not None
(value := sig_class_value(sig)) is not None
and value != RAD_SIG_CLASS_VALUE
for sig in variant.component_sigs
)


def _has_unclassified_sig(variant) -> bool:
return any(_sig_class_value(sig) is None for sig in variant.component_sigs)
return any(sig_class_value(sig) is None for sig in variant.component_sigs)


def is_concurrent_chemort(variant) -> bool:
Expand Down
18 changes: 18 additions & 0 deletions hemonc_alchemy/toolkit/analytics/treatment/scheduling/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,41 @@
home_administered_sigs_by_drug,
schedule_events,
)
from .rollout import (
AnchoredBlock,
CycleBlock,
TimedEvent,
UnresolvedTiming,
anchor_blocks,
group_into_blocks,
roll_out_phase,
roll_out_variant,
)
from .routes import route_group
from .tokens import Choice, Day, Indefinite, Range

__all__ = [
"AnchoredBlock",
"Choice",
"CycleBlock",
"Day",
"Indefinite",
"Range",
"ResolvedSchedule",
"ScheduleEvent",
"TimedEvent",
"UnresolvedTiming",
"administration_frame",
"administration_matrix",
"anchor_blocks",
"cancer_services_drugs",
"cancer_services_sigs_by_drug",
"group_into_blocks",
"home_administered_drugs",
"home_administered_sigs_by_drug",
"resolve_all_days",
"roll_out_phase",
"roll_out_variant",
"route_group",
"schedule_events",
]
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ def parse_scalar_list(token: str):
elif "|" in part:
out.append(parse_choice(part))
else:
out.append(Day(int(part)))
try:
out.append(Day(int(part)))
except ValueError:
logger.warning("Unparseable dosing token %r dropped", part)
return out


Expand All @@ -102,6 +105,8 @@ def parse_optional(token: str):
inner = token[1:-1]

if inner.startswith("+"):
if match := re.fullmatch(r"\+([1-9][0-9]*)", inner):
return [Indefinite("+k", interval=int(match.group(1)))]
match = re.fullmatch(r"\+([a-zA-Z])(\d+)?", inner)
if not match:
logger.warning("Unparseable indefinite-dosing token %r", token)
Expand Down Expand Up @@ -148,9 +153,8 @@ def expand(parsed) -> ResolvedSchedule:
)
else:
indefinite = item
logger.warning(
"Indefinite-dosing marker %r found (continue until progression/indefinitely); "
"explicit days list is not the complete schedule",
logger.debug(
"Indefinite-dosing marker %r found; explicit days list is not the complete schedule",
item,
)

Expand Down
94 changes: 59 additions & 35 deletions hemonc_alchemy/toolkit/analytics/treatment/scheduling/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
Sigs_FrequencyEnum,
Sigs_PhaseEnum,
)
from .handling import Day, Indefinite, apply_sig_to_series, resolve_all_days
from .handling import Day, Indefinite, resolve_all_days
from .routes import route_group

DEFAULT_DECAY_DAYS = 2
Expand All @@ -48,6 +48,9 @@
"intensity",
"optional",
"indefinite",
"cycle_indefinite",
"elapsed_day",
"timing_status",
]


Expand Down Expand Up @@ -143,6 +146,42 @@ def home_administered_sigs_by_drug(variant) -> dict:
return _sigs_by_drug_where(variant, "PO")


def _rollout_frame_records(
variant,
*,
decay_days: int,
decay_factor: float,
) -> list[dict]:
from .rollout import roll_out_variant

rolled = roll_out_variant(
variant,
decay_days=decay_days,
decay_factor=decay_factor,
)
records = []
for row in rolled.itertuples(index=False):
if row.route_group is None or row.drug_cui is None:
continue
records.append(
{
"variant_cui": row.variant_cui,
"variant": row.variant,
"route_group": row.route_group,
"drug_cui": row.drug_cui,
"drug": row.drug,
"day": row.day,
"intensity": row.intensity,
"optional": row.optional,
"indefinite": row.day_indefinite,
"cycle_indefinite": row.cycle_indefinite,
"elapsed_day": row.elapsed_day,
"timing_status": row.timing_status,
}
)
return records


def administration_frame(
variants,
*,
Expand All @@ -159,18 +198,21 @@ def administration_frame(
| `route_group` | `"IV"` (clinic) or `"PO"` (home) |
| `drug_cui`, `drug` | the drug, by identifier and by name |
| `day` | day of cycle; can be negative for lead-in dosing |
| `elapsed_day` | variant-relative day when cross-cycle timing resolves |
| `intensity` | 1.0 on a dosing day, tapering over `decay_days` after |
| `optional` | whether the dosing day itself was marked optional |
| `indefinite` | set when the sig continues past its stated days |
| `indefinite` | day-level marker when days continue past those stated |
| `cycle_indefinite` | cycle-level marker when cycles continue past those stated |
| `timing_status` | whether the rollout is resolved or needs review |

`intensity` tapers after each dose by `decay_factor` per day for
`decay_days`, so a treatment day and the days it encroaches on both
register. Set `decay_days=0` for dosing days alone.

Rows whose route is unrecognised or not specified are excluded, as are
sigs with no resolvable days -- including open-ended `EOC` ranges, so a
variant can legitimately produce no rows. Where `indefinite` is set, the
days present are only the part that was written down.
variant can legitimately produce no rows. Where `indefinite` or
`cycle_indefinite` is set, only the written days or cycles are returned.
"""
# Duck-typed rather than `isinstance(variants, Iterable)`: entities inherit
# __iter__ from orm-loader's serialisation interface, so a single variant
Expand All @@ -181,34 +223,11 @@ def administration_frame(
records: list[dict] = []

for variant in variants:
for event in schedule_events(variant):
drug = event.drug_object
if event.route_group is None or drug is None or not event.days:
continue

series: dict[int, float] = defaultdict(float)
apply_sig_to_series(
series,
list(event.days),
decay_days=decay_days,
decay_factor=decay_factor,
)
optional_days = {day.value for day in event.days if day.optional}

for day, intensity in series.items():
records.append(
{
"variant_cui": variant.variant_cui,
"variant": variant.variant,
"route_group": event.route_group,
"drug_cui": drug.drug_cui,
"drug": drug.drug,
"day": day,
"intensity": intensity,
"optional": day in optional_days,
"indefinite": event.indefinite,
}
)
records.extend(_rollout_frame_records(
variant,
decay_days=decay_days,
decay_factor=decay_factor,
))

if not records:
return pd.DataFrame(columns=_FRAME_COLUMNS)
Expand All @@ -219,15 +238,20 @@ def administration_frame(
# decay tails can land on the same day; keep the strongest.
grouped = (
frame.groupby(
["variant_cui", "variant", "route_group", "drug_cui", "drug", "day"],
[
"variant_cui", "variant", "route_group", "drug_cui", "drug",
"day", "elapsed_day",
],
as_index=False,
dropna=False,
)
.agg(intensity=("intensity", "max"), optional=("optional", "all"),
indefinite=("indefinite", "first"))
indefinite=("indefinite", "first"),
cycle_indefinite=("cycle_indefinite", "first"),
Comment thread
gkennos marked this conversation as resolved.
timing_status=("timing_status", "first"))
)
return grouped[_FRAME_COLUMNS].sort_values(
["variant_cui", "route_group", "drug", "day"], ignore_index=True
["variant_cui", "route_group", "drug", "elapsed_day", "day"], ignore_index=True
)


Expand Down
Loading
Loading