"""Canonical pharmacometric event records.
The event layer is deliberately independent of pandas. Input records are
normalized to immutable :class:`CanonicalEvent` objects, sorted with a
documented tie-breaking rule, and accompanied by an immutable audit trail.
Same-time events are applied in this order:
1. reset (including the reset part of reset-and-dose);
2. covariate change;
3. infusion stop;
4. dose or infusion start (including the dose part of reset-and-dose);
5. observation;
6. other event.
Input order is the final stable tie breaker. Consequently, an observation at
the same time as a dose sees the post-dose state. This convention is explicit
instead of being inherited from a dataframe sort implementation.
"""
from __future__ import annotations
from collections import Counter
from collections.abc import Hashable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field, replace
from datetime import datetime
from enum import IntEnum, StrEnum
from math import isclose, isfinite
from types import MappingProxyType
from typing import Any
import numpy as np
[docs]
class EventValidationError(ValueError):
"""Raised when an event record is ambiguous or internally inconsistent."""
code = "EVENT-INVALID-001"
def __init__(self, message: str, *, row: int | str | None = None) -> None:
self.row = row
suffix = "" if row is None else f" [row={row!r}]"
super().__init__(message + suffix)
[docs]
class EventType(IntEnum):
"""Canonical EVID values, extending the common NONMEM values.
Values 0--4 retain their familiar meaning. Values 5 and 6 are explicit
PyMixEF records used for time-varying covariates and infusion stops.
"""
OBSERVATION = 0
DOSE = 1
OTHER = 2
RESET = 3
RESET_AND_DOSE = 4
COVARIATE = 5
INFUSION_STOP = 6
[docs]
class DoseAmountStatus(StrEnum):
"""Semantic state of the dose-amount field."""
RECORDED = "recorded"
UNKNOWN = "unknown"
NOT_APPLICABLE = "not-applicable"
_EVENT_NAMES: dict[str, EventType] = {
"observation": EventType.OBSERVATION,
"observe": EventType.OBSERVATION,
"obs": EventType.OBSERVATION,
"dose": EventType.DOSE,
"bolus": EventType.DOSE,
"infusion": EventType.DOSE,
"infusion_start": EventType.DOSE,
"other": EventType.OTHER,
"reset": EventType.RESET,
"reset_and_dose": EventType.RESET_AND_DOSE,
"reset-dose": EventType.RESET_AND_DOSE,
"resetdose": EventType.RESET_AND_DOSE,
"covariate": EventType.COVARIATE,
"covariate_change": EventType.COVARIATE,
"infusion_stop": EventType.INFUSION_STOP,
}
# Reset-and-dose has the priority of reset. The simulator performs its reset
# before adding the dose represented by the same record.
EVENT_PRIORITY: Mapping[EventType, int] = MappingProxyType(
{
EventType.RESET: 0,
EventType.RESET_AND_DOSE: 0,
EventType.COVARIATE: 1,
EventType.INFUSION_STOP: 2,
EventType.DOSE: 3,
EventType.OBSERVATION: 4,
EventType.OTHER: 5,
}
)
def _freeze_value(value: Any) -> Any:
if isinstance(value, Mapping):
return _freeze_mapping(value)
if isinstance(value, (list, tuple)):
return tuple(_freeze_value(item) for item in value)
if isinstance(value, (set, frozenset)):
return frozenset(_freeze_value(item) for item in value)
if isinstance(value, np.ndarray):
array = np.array(value, copy=True)
array.setflags(write=False)
return array
return value
def _freeze_mapping(value: Mapping[str, Any] | None) -> Mapping[str, Any]:
if not value:
return MappingProxyType({})
return MappingProxyType({str(key): _freeze_value(item) for key, item in value.items()})
def _json_value(value: Any) -> Any:
"""Return a plain serialization-friendly representation."""
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, tuple):
return [_json_value(item) for item in value]
if isinstance(value, frozenset):
return [_json_value(item) for item in sorted(value, key=repr)]
if isinstance(value, np.ndarray):
return value.tolist()
if isinstance(value, np.generic):
return value.item()
if isinstance(value, EventType):
return int(value)
return value
[docs]
@dataclass(frozen=True, slots=True)
class AuditEntry:
"""One machine-readable canonicalization or expansion action."""
code: str
action: str
row_id: str | None = None
source_row_id: str | None = None
details: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
def __post_init__(self) -> None:
object.__setattr__(self, "details", _freeze_mapping(self.details))
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialize the audit entry without exposing mutable internals."""
return {
"code": self.code,
"action": self.action,
"row_id": self.row_id,
"source_row_id": self.source_row_id,
"details": _json_value(self.details),
}
[docs]
@dataclass(frozen=True, slots=True)
class CanonicalEvent:
"""An immutable event in the PyMixEF canonical schema.
``amount_status`` distinguishes a recorded amount (including zero), an
explicitly unknown dose amount, and a field that is structurally not
applicable to a non-dose event.
"""
subject_id: Hashable
time: float
evid: EventType
amount: float | None = None
amount_status: DoseAmountStatus | str | None = None
rate: float | None = None
duration: float | None = None
compartment: int | str | None = None
additional: int = 0
interval: float | None = None
steady_state: int = 0
mdv: int = 1
dv: float | None = None
lloq: float | None = None
occasion: Hashable | None = None
bioavailability: float = 1.0
lag: float = 0.0
covariates: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
extras: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
row_id: str = ""
source_row_id: str = ""
source_position: int = 0
generated: bool = False
generation: str | None = None
def __post_init__(self) -> None:
event_type = EventType(self.evid)
if self.amount_status is None:
amount_status = (
DoseAmountStatus.RECORDED
if self.amount is not None
else (
DoseAmountStatus.UNKNOWN
if event_type in (EventType.DOSE, EventType.RESET_AND_DOSE)
else DoseAmountStatus.NOT_APPLICABLE
)
)
else:
try:
amount_status = DoseAmountStatus(str(self.amount_status))
except ValueError as error:
raise EventValidationError(
"AMT_STATUS must be recorded, unknown, or not-applicable",
row=self.row_id,
) from error
if amount_status == DoseAmountStatus.RECORDED and self.amount is None:
raise EventValidationError(
"AMT_STATUS=recorded requires AMT, including AMT=0 for zero dose",
row=self.row_id,
)
if amount_status != DoseAmountStatus.RECORDED and self.amount is not None:
raise EventValidationError(
"AMT is present but AMT_STATUS is not recorded", row=self.row_id
)
if (
event_type in (EventType.DOSE, EventType.RESET_AND_DOSE)
and amount_status == DoseAmountStatus.NOT_APPLICABLE
):
raise EventValidationError(
"Dose AMT may be recorded or explicitly unknown, not not-applicable",
row=self.row_id,
)
if not isinstance(self.subject_id, Hashable):
raise EventValidationError("ID must be hashable", row=self.source_position)
if self.compartment is not None and not isinstance(
self.compartment, (int, str, np.integer)
):
raise EventValidationError("CMT must be an integer, string, or missing")
if not isfinite(float(self.time)):
raise EventValidationError("TIME must be finite", row=self.source_position)
if self.additional < 0:
raise EventValidationError("ADDL must be a non-negative integer", row=self.row_id)
if self.additional and (self.interval is None or self.interval <= 0):
raise EventValidationError("ADDL > 0 requires II > 0", row=self.row_id)
if self.interval is not None and (not isfinite(self.interval) or self.interval <= 0):
raise EventValidationError("II must be finite and positive", row=self.row_id)
if self.amount is not None and not isfinite(self.amount):
raise EventValidationError("AMT must be finite when present", row=self.row_id)
if self.rate is not None and (not isfinite(self.rate) or self.rate < 0):
raise EventValidationError("RATE must be finite and non-negative", row=self.row_id)
if self.duration is not None and (not isfinite(self.duration) or self.duration <= 0):
raise EventValidationError("DUR must be finite and positive", row=self.row_id)
if self.bioavailability < 0 or not isfinite(self.bioavailability):
raise EventValidationError("bioavailability must be finite and non-negative")
if self.lag < 0 or not isfinite(self.lag):
raise EventValidationError("lag must be finite and non-negative")
if self.mdv not in (0, 1):
raise EventValidationError("MDV must be 0 or 1", row=self.row_id)
if self.steady_state not in (0, 1, 2, 3):
raise EventValidationError("SS must be one of 0, 1, 2, or 3", row=self.row_id)
object.__setattr__(self, "evid", event_type)
object.__setattr__(self, "amount_status", amount_status)
object.__setattr__(self, "time", float(self.time))
object.__setattr__(self, "covariates", _freeze_mapping(self.covariates))
object.__setattr__(self, "extras", _freeze_mapping(self.extras))
@property
def is_observation(self) -> bool:
return self.evid == EventType.OBSERVATION
@property
def is_dose(self) -> bool:
return self.evid in (EventType.DOSE, EventType.RESET_AND_DOSE)
@property
def is_reset(self) -> bool:
return self.evid in (EventType.RESET, EventType.RESET_AND_DOSE)
@property
def is_infusion(self) -> bool:
return self.is_dose and self.effective_rate > 0
@property
def effective_rate(self) -> float:
"""Infusion rate after bioavailability, or zero for a bolus."""
if self.rate is not None and self.rate > 0:
return self.rate * self.bioavailability
if self.duration is not None and self.amount is not None:
return (self.amount / self.duration) * self.bioavailability
return 0.0
@property
def effective_amount(self) -> float | None:
return None if self.amount is None else self.amount * self.bioavailability
@property
def infusion_duration(self) -> float | None:
if not self.is_infusion:
return None
if self.duration is not None:
return self.duration
if self.amount is not None and self.rate:
return self.amount / self.rate
return None
@property
def kind(self) -> str:
if self.evid == EventType.DOSE and self.is_infusion:
return "infusion_start"
return {
EventType.OBSERVATION: "observation",
EventType.DOSE: "dose",
EventType.OTHER: "other",
EventType.RESET: "reset",
EventType.RESET_AND_DOSE: "reset_and_dose",
EventType.COVARIATE: "covariate",
EventType.INFUSION_STOP: "infusion_stop",
}[self.evid]
id = property(lambda self: self.subject_id)
event_type = property(lambda self: self.kind)
amt = property(lambda self: self.amount)
cmt = property(lambda self: self.compartment)
addl = property(lambda self: self.additional)
ii = property(lambda self: self.interval)
ss = property(lambda self: self.steady_state)
# Uppercase aliases make the canonical schema discoverable to users coming
# from NONMEM-style data without making Python attribute names awkward.
ID = property(lambda self: self.subject_id)
TIME = property(lambda self: self.time)
EVID = property(lambda self: int(self.evid))
AMT = property(lambda self: self.amount)
RATE = property(lambda self: self.rate)
DUR = property(lambda self: self.duration)
CMT = property(lambda self: self.compartment)
ADDL = property(lambda self: self.additional)
II = property(lambda self: self.interval)
SS = property(lambda self: self.steady_state)
MDV = property(lambda self: self.mdv)
DV = property(lambda self: self.dv)
LLOQ = property(lambda self: self.lloq)
[docs]
def to_record(self) -> dict[str, Any]:
"""Return a mutable, uppercase-keyed record for serialization."""
result: dict[str, Any] = {
"ID": self.subject_id,
"TIME": self.time,
"EVID": int(self.evid),
"AMT": self.amount,
"AMT_STATUS": self.amount_status.value,
"RATE": self.rate,
"DUR": self.duration,
"CMT": self.compartment,
"ADDL": self.additional,
"II": self.interval,
"SS": self.steady_state,
"MDV": self.mdv,
"DV": self.dv,
"LLOQ": self.lloq,
"OCCASION": self.occasion,
"F": self.bioavailability,
"ALAG": self.lag,
"ROW_ID": self.row_id,
"SOURCE_ROW_ID": self.source_row_id,
"GENERATED": self.generated,
"GENERATION": self.generation,
}
result.update(_json_value(self.covariates))
result.update(_json_value(self.extras))
return result
_ALIASES: dict[str, tuple[str, ...]] = {
"id": ("ID", "id", "subject_id", "subject", "SUBJ"),
"time": ("TIME", "time"),
"evid": ("EVID", "evid", "event", "event_type"),
"amount": ("AMT", "amt", "amount"),
"amount_status": ("AMT_STATUS", "amt_status", "amount_status"),
"rate": ("RATE", "rate"),
"duration": ("DUR", "dur", "duration"),
"compartment": ("CMT", "cmt", "compartment"),
"additional": ("ADDL", "addl", "additional"),
"interval": ("II", "ii", "interval"),
"steady_state": ("SS", "ss", "steady_state"),
"mdv": ("MDV", "mdv"),
"dv": ("DV", "dv", "observation"),
"lloq": ("LLOQ", "lloq"),
"occasion": ("OCCASION", "occasion", "OCC", "occ"),
"bioavailability": ("F", "f", "bioavailability"),
"lag": ("ALAG", "alag", "lag"),
"row_id": ("ROW_ID", "row_id"),
"source_row_id": ("SOURCE_ROW_ID", "source_row_id"),
"covariates": ("COVARIATES", "covariates"),
}
_KNOWN_KEYS = {alias for aliases in _ALIASES.values() for alias in aliases}
def _first(record: Mapping[str, Any], name: str, default: Any = None) -> Any:
for alias in _ALIASES[name]:
if alias in record:
value = record[alias]
if _is_missing(value):
return default
return value
return default
def _is_missing(value: Any) -> bool:
if value is None:
return True
if isinstance(value, (float, np.floating)):
return bool(np.isnan(value))
return False
def _number(value: Any, *, name: str, row: int, integer: bool = False) -> float | int | None:
if _is_missing(value):
return None
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise EventValidationError(f"{name} must be numeric", row=row) from exc
if not isfinite(number):
raise EventValidationError(f"{name} must be finite", row=row)
if integer:
rounded = int(number)
if number != rounded:
raise EventValidationError(f"{name} must be an integer", row=row)
return rounded
return number
def _event_type(value: Any, *, row: int) -> EventType:
if value is None:
return EventType.OBSERVATION
if isinstance(value, str):
key = value.strip().lower().replace(" ", "_")
if key in _EVENT_NAMES:
return _EVENT_NAMES[key]
try:
return EventType(int(value))
except (TypeError, ValueError) as exc:
raise EventValidationError(f"unsupported EVID/event type {value!r}", row=row) from exc
def _time(value: Any, *, row: int) -> tuple[float, bool]:
if isinstance(value, datetime):
if value.tzinfo is None or value.utcoffset() is None:
raise EventValidationError("calendar TIME values must be timezone-aware", row=row)
return float(value.timestamp()), True
converted = _number(value, name="TIME", row=row)
if converted is None:
raise EventValidationError("TIME is required", row=row)
return float(converted), False
def _normalize_record(
record: Mapping[str, Any],
position: int,
*,
covariate_columns: Sequence[str],
) -> tuple[CanonicalEvent, tuple[AuditEntry, ...]]:
subject_id = _first(record, "id")
if _is_missing(subject_id):
raise EventValidationError("ID is required", row=position)
canonical_time, converted_calendar = _time(_first(record, "time"), row=position)
evid = _event_type(_first(record, "evid"), row=position)
amount = _number(_first(record, "amount"), name="AMT", row=position)
raw_amount_status = _first(record, "amount_status")
if raw_amount_status is None:
amount_status = (
DoseAmountStatus.RECORDED
if amount is not None
else (
DoseAmountStatus.UNKNOWN
if evid in (EventType.DOSE, EventType.RESET_AND_DOSE)
else DoseAmountStatus.NOT_APPLICABLE
)
)
else:
try:
amount_status = DoseAmountStatus(
str(raw_amount_status).strip().lower().replace("_", "-")
)
except ValueError as error:
raise EventValidationError(
"AMT_STATUS must be recorded, unknown, or not-applicable",
row=position,
) from error
rate = _number(_first(record, "rate"), name="RATE", row=position)
duration = _number(_first(record, "duration"), name="DUR", row=position)
additional = _number(_first(record, "additional", 0), name="ADDL", row=position, integer=True)
interval = _number(_first(record, "interval"), name="II", row=position)
steady_state = _number(_first(record, "steady_state", 0), name="SS", row=position, integer=True)
dv = _number(_first(record, "dv"), name="DV", row=position)
lloq = _number(_first(record, "lloq"), name="LLOQ", row=position)
supplied_mdv = _first(record, "mdv")
mdv = (
int(_number(supplied_mdv, name="MDV", row=position, integer=True))
if supplied_mdv is not None
else int(not (evid == EventType.OBSERVATION and dv is not None))
)
bioavailability = _number(_first(record, "bioavailability", 1.0), name="F", row=position)
lag = _number(_first(record, "lag", 0.0), name="ALAG", row=position)
if (
evid in (EventType.DOSE, EventType.RESET_AND_DOSE)
and amount is None
and raw_amount_status is None
):
raise EventValidationError(
"dose records without AMT require AMT_STATUS='unknown'; use AMT=0 "
"for a recorded zero dose",
row=position,
)
if evid == EventType.INFUSION_STOP and (rate is None or rate <= 0):
raise EventValidationError("an explicit infusion stop requires RATE > 0", row=position)
if rate is not None and rate < 0:
raise EventValidationError(
"negative or modeled RATE values are not silently interpreted", row=position
)
if duration is not None and duration <= 0:
raise EventValidationError("DUR must be positive", row=position)
if rate == 0:
rate = None
if duration is not None and rate is None:
if amount is None:
raise EventValidationError("DUR without RATE requires AMT", row=position)
rate = amount / duration
elif rate is not None and rate > 0 and duration is None and amount is not None:
duration = amount / rate
elif rate is not None and duration is not None and amount is not None:
if not isclose(amount, rate * duration, rel_tol=1e-9, abs_tol=1e-12):
raise EventValidationError(
"AMT, RATE, and DUR are inconsistent (expected AMT = RATE * DUR)",
row=position,
)
raw_row_id = _first(record, "row_id")
row_id = str(raw_row_id) if raw_row_id is not None else f"row-{position:08d}"
raw_source = _first(record, "source_row_id")
source_row_id = str(raw_source) if raw_source is not None else row_id
explicit_covariates = _first(record, "covariates", {})
if explicit_covariates is None:
explicit_covariates = {}
if not isinstance(explicit_covariates, Mapping):
raise EventValidationError("COVARIATES must be a mapping", row=position)
covariates = dict(explicit_covariates)
for column in covariate_columns:
if column in record and not _is_missing(record[column]):
covariates[column] = record[column]
extras = {
str(key): value
for key, value in record.items()
if key not in _KNOWN_KEYS and key not in covariate_columns
}
event = CanonicalEvent(
subject_id=subject_id,
time=canonical_time,
evid=evid,
amount=None if amount is None else float(amount),
amount_status=amount_status,
rate=None if rate is None else float(rate),
duration=None if duration is None else float(duration),
compartment=_first(record, "compartment"),
additional=int(additional or 0),
interval=None if interval is None else float(interval),
steady_state=int(steady_state or 0),
mdv=mdv,
dv=None if dv is None else float(dv),
lloq=None if lloq is None else float(lloq),
occasion=_first(record, "occasion"),
bioavailability=float(bioavailability if bioavailability is not None else 1.0),
lag=float(lag if lag is not None else 0.0),
covariates=covariates,
extras=extras,
row_id=row_id,
source_row_id=source_row_id,
source_position=position,
)
audit: list[AuditEntry] = [
AuditEntry(
code="EVENT-CANONICALIZED-001",
action="canonicalized",
row_id=row_id,
source_row_id=source_row_id,
details={"source_position": position},
)
]
if converted_calendar:
audit.append(
AuditEntry(
code="EVENT-TIME-CONVERTED-001",
action="calendar_time_to_utc_unix_seconds",
row_id=row_id,
source_row_id=source_row_id,
details={"time": canonical_time},
)
)
if duration is not None and _first(record, "rate") is None:
audit.append(
AuditEntry(
code="EVENT-RATE-DERIVED-001",
action="derived_rate_from_amount_and_duration",
row_id=row_id,
source_row_id=source_row_id,
details={"rate": rate},
)
)
if duration is not None and _first(record, "duration") is None:
audit.append(
AuditEntry(
code="EVENT-DURATION-DERIVED-001",
action="derived_duration_from_amount_and_rate",
row_id=row_id,
source_row_id=source_row_id,
details={"duration": duration},
)
)
return event, tuple(audit)
def _subject_key(value: Hashable) -> tuple[str, str]:
"""Provide deterministic ordering even for heterogeneous subject IDs."""
return type(value).__qualname__, repr(value)
def _sort_key(event: CanonicalEvent) -> tuple[Any, ...]:
return (
_subject_key(event.subject_id),
event.time,
EVENT_PRIORITY[event.evid],
event.source_position,
event.row_id,
)
[docs]
@dataclass(frozen=True, slots=True)
class EventTable(Sequence[CanonicalEvent]):
"""Immutable, deterministically ordered collection of canonical events."""
events: tuple[CanonicalEvent, ...]
audit: tuple[AuditEntry, ...] = ()
source_count: int = 0
source_records: tuple[Mapping[str, Any], ...] = ()
def __post_init__(self) -> None:
normalized = tuple(sorted(tuple(self.events), key=_sort_key))
ids = [event.row_id for event in normalized]
if len(ids) != len(set(ids)):
duplicate = next(row_id for row_id in ids if ids.count(row_id) > 1)
raise EventValidationError(f"ROW_ID values must be unique; duplicate {duplicate!r}")
object.__setattr__(self, "events", normalized)
object.__setattr__(self, "audit", tuple(self.audit))
object.__setattr__(
self,
"source_records",
tuple(_freeze_mapping(record) for record in self.source_records),
)
if self.source_count == 0 and normalized:
object.__setattr__(
self, "source_count", len({event.source_row_id for event in normalized})
)
[docs]
@classmethod
def from_records(
cls,
records: Iterable[Mapping[str, Any]] | np.ndarray,
*,
covariate_columns: Sequence[str] = (),
expand_additional: bool = False,
expand_infusions: bool = False,
) -> EventTable:
"""Canonicalize mapping records or a NumPy structured array.
Calendar times are converted to UTC Unix seconds and must be
timezone-aware. No input object is modified.
"""
if isinstance(records, np.ndarray) and records.dtype.names:
iterable: Iterable[Mapping[str, Any]] = (
{name: row[name] for name in records.dtype.names} for row in records
)
else:
iterable = records
events: list[CanonicalEvent] = []
audit: list[AuditEntry] = []
source_records: list[Mapping[str, Any]] = []
for position, record in enumerate(iterable):
if not isinstance(record, Mapping):
raise TypeError("each event record must be a mapping")
source_records.append(_freeze_mapping(record))
event, event_audit = _normalize_record(
record, position, covariate_columns=covariate_columns
)
events.append(event)
audit.extend(event_audit)
table = cls(
tuple(events),
tuple(audit),
source_count=len(events),
source_records=tuple(source_records),
)
if expand_additional:
table = table.expand_additional()
if expand_infusions:
table = table.expand_infusions()
return table
def __len__(self) -> int:
return len(self.events)
def __iter__(self) -> Iterator[CanonicalEvent]:
return iter(self.events)
def __getitem__(self, index: int | slice) -> CanonicalEvent | tuple[CanonicalEvent, ...]:
return self.events[index]
@property
def subjects(self) -> tuple[Hashable, ...]:
"""Subject IDs in deterministic first-occurrence order."""
return tuple(dict.fromkeys(event.subject_id for event in self.events))
[docs]
def for_subject(self, subject_id: Hashable) -> EventTable:
"""Return an immutable view containing one subject."""
selected = tuple(event for event in self.events if event.subject_id == subject_id)
return EventTable(selected, self.audit, self.source_count, self.source_records)
[docs]
def to_records(self) -> list[dict[str, Any]]:
"""Return plain records suitable for pandas, Polars, Arrow, or JSON."""
return [event.to_record() for event in self.events]
[docs]
def to_source_records(self) -> list[dict[str, Any]]:
"""Return fresh copies of the retained, pre-canonicalization records."""
return [
{str(key): _json_value(value) for key, value in record.items()}
for record in self.source_records
]
[docs]
def expand_additional(self) -> EventTable:
"""Materialize ADDL/II doses without mutating the source table.
The returned records have ``ADDL=0`` so expansion is idempotent. Every
generated row retains the original ``source_row_id`` and receives a
deterministic ``<row_id>:addl:<n>`` row identifier.
"""
expanded: list[CanonicalEvent] = []
audit = list(self.audit)
for event in self.events:
if event.additional == 0:
expanded.append(event)
continue
if not event.is_dose:
raise EventValidationError("ADDL is only valid on dose records", row=event.row_id)
assert event.interval is not None # validated by CanonicalEvent
expanded.append(replace(event, additional=0))
for number in range(1, event.additional + 1):
generated = replace(
event,
time=event.time + number * event.interval,
additional=0,
row_id=f"{event.row_id}:addl:{number}",
generated=True,
generation="ADDL",
source_position=event.source_position,
)
expanded.append(generated)
audit.append(
AuditEntry(
code="EVENT-ADDL-EXPANDED-001",
action="generated_additional_dose",
row_id=generated.row_id,
source_row_id=event.source_row_id,
details={
"dose_number": number,
"time": generated.time,
"interval": event.interval,
},
)
)
return EventTable(tuple(expanded), tuple(audit), self.source_count, self.source_records)
[docs]
def expand_infusions(self) -> EventTable:
"""Add explicit infusion-stop records for finite infusions.
Explicit stops already present in the source are retained. A finite
start generated from ``AMT/RATE/DUR`` gets one deterministic stop.
"""
expanded: list[CanonicalEvent] = list(self.events)
audit = list(self.audit)
explicit_stops = Counter(
(
event.subject_id,
event.time,
event.compartment,
round(event.effective_rate, 12),
)
for event in self.events
if event.evid == EventType.INFUSION_STOP
)
for event in self.events:
if not event.is_infusion:
continue
duration = event.infusion_duration
if duration is None:
raise EventValidationError(
"finite infusion requires DUR or both AMT and RATE", row=event.row_id
)
row_id = f"{event.row_id}:infusion-stop"
stop_key = (
event.subject_id,
event.time + event.lag + duration,
event.compartment,
round(event.effective_rate, 12),
)
if explicit_stops[stop_key]:
explicit_stops[stop_key] -= 1
continue
stop = CanonicalEvent(
subject_id=event.subject_id,
time=event.time + event.lag + duration,
evid=EventType.INFUSION_STOP,
rate=event.effective_rate,
compartment=event.compartment,
occasion=event.occasion,
covariates={},
row_id=row_id,
source_row_id=event.source_row_id,
source_position=event.source_position,
generated=True,
generation="INFUSION_STOP",
)
expanded.append(stop)
audit.append(
AuditEntry(
code="EVENT-INFUSION-EXPANDED-001",
action="generated_infusion_stop",
row_id=row_id,
source_row_id=event.source_row_id,
details={"time": stop.time, "rate": stop.rate},
)
)
return EventTable(tuple(expanded), tuple(audit), self.source_count, self.source_records)
[docs]
def provenance(self) -> tuple[dict[str, Any], ...]:
"""Return the complete immutable audit as fresh dictionaries."""
return tuple(entry.to_dict() for entry in self.audit)
[docs]
def canonicalize_events(
records: Iterable[Mapping[str, Any]] | np.ndarray,
*,
covariate_columns: Sequence[str] = (),
expand_additional: bool = False,
expand_infusions: bool = False,
) -> EventTable:
"""Convenience wrapper around :meth:`EventTable.from_records`."""
return EventTable.from_records(
records,
covariate_columns=covariate_columns,
expand_additional=expand_additional,
expand_infusions=expand_infusions,
)
__all__ = [
"EVENT_PRIORITY",
"AuditEntry",
"CanonicalEvent",
"DoseAmountStatus",
"EventTable",
"EventType",
"EventValidationError",
"canonicalize_events",
]