7. Canonical pharmacometrics event semantics

Field: pharmacometrics and clinical pharmacology
Analysis: deterministic event canonicalization, ordering, expansion, and audit
Records: dose, observation, covariate, infusion start, and infusion stop

Download the complete, pre-executed notebook

7.1. Domain and problem

Pharmacometric data are event streams, not merely rectangular numeric tables. A dose changes a model state, an observation reads a model state, a covariate record updates context, a reset clears state, and an infusion stop changes the input rate. These distinctions become especially important when multiple records share a time or when a compact source row implies additional events.

This tutorial converts ordinary Python mappings into PyMixEF’s immutable pymixef.pharmacometrics.events.EventTable. It then makes ordering, ADDL/II dose expansion, finite-infusion expansion, source-row provenance, and amount-status semantics inspectable.

For a finite infusion with amount \(D\) and duration \(\tau\), canonicalization derives the constant rate and terminal event from

\[ R_\mathrm{in}=\frac{D}{\tau}, \qquad t_\mathrm{stop}=t_\mathrm{start}+\tau. \]

For additional doses, the generated time of dose \(k\) is \(t_k=t_0+k\,\mathrm{II}\) for \(k=1,\ldots,\mathrm{ADDL}\). These equations are simple; preserving their source-row provenance and same-time priority is the important part of the event contract.

7.2. What you will learn

By the end of the tutorial, you will be able to:

  1. canonicalize dose, observation, covariate, and infusion records;

  2. inspect deterministic subject/time/event ordering;

  3. understand which state a same-time observation sees;

  4. materialize additional doses and generated infusion-stop events;

  5. trace every generated row to its source row through the audit log;

  6. distinguish a recorded zero dose, an explicitly unknown dose, and an amount that is structurally not applicable; and

  7. use validation errors as an explicit part of an event-data workflow.

7.3. Dataset and event snapshot

Item

Value

Subjects

P01, P02

Source rows

7

Expanded rows

10

Generated rows

3

P01 regimen

100-unit bolus at 0 h plus 2 additional doses every 12 h

P02 regimen

60-unit infusion over 2 h

Derived P02 rate

30 units/hour

P01 observation times

0 h and 24 h

P02 observation times

0 h and 3 h

P01 covariate update

Weight 72 at 12 h

The example deliberately places observations at the same time as a dose or infusion start so that ordering is visible rather than implicit.

7.4. Runnable core analysis

The following excerpt reproduces the central event workflow.

from pymixef.pharmacometrics import canonicalize_events

source_records = [
    {
        "ID": "P01",
        "TIME": 0,
        "EVID": "dose",
        "AMT": 100,
        "CMT": "central",
        "ADDL": 2,
        "II": 12,
        "ROW_ID": "dose-p01",
    },
    {
        "ID": "P01",
        "TIME": 0,
        "EVID": 0,
        "DV": 4.8,
        "CMT": "central",
        "ROW_ID": "obs-p01-0",
    },
    {
        "ID": "P01",
        "TIME": 12,
        "EVID": "covariate",
        "COVARIATES": {"weight": 72},
        "ROW_ID": "wt-p01",
    },
    {
        "ID": "P01",
        "TIME": 24,
        "EVID": 0,
        "DV": 1.1,
        "CMT": "central",
        "ROW_ID": "obs-p01-24",
    },
    {
        "ID": "P02",
        "TIME": 0,
        "EVID": "dose",
        "AMT": 60,
        "DUR": 2,
        "CMT": "central",
        "ROW_ID": "inf-p02",
    },
    {
        "ID": "P02",
        "TIME": 0,
        "EVID": 0,
        "DV": 0,
        "CMT": "central",
        "ROW_ID": "obs-p02-0",
    },
    {
        "ID": "P02",
        "TIME": 3,
        "EVID": 0,
        "DV": 2.2,
        "CMT": "central",
        "ROW_ID": "obs-p02-3",
    },
]

events = canonicalize_events(source_records)
same_time_p01 = [
    event.kind
    for event in events
    if event.subject_id == "P01" and event.time == 0
]
assert same_time_p01 == ["dose", "observation"]

expanded = events.expand_additional().expand_infusions()
generated = [event for event in expanded if event.generated]
assert len(expanded) == 10
assert len(generated) == 3

7.5. Step-by-step analysis

7.5.1. 1. Canonicalize heterogeneous source rows

canonicalize_events(...) accepts the source mappings, validates them, assigns event kinds, derives the finite-infusion rate, retains source identifiers, and returns a deterministically sorted EventTable.

The canonical rows are:

Subject

Time (h)

Kind

Amount

Rate

Row ID

P01

0

dose

100

dose-p01

P01

0

observation

obs-p01-0

P01

12

covariate

wt-p01

P01

24

observation

obs-p01-24

P02

0

infusion start

60

30

inf-p02

P02

0

observation

obs-p02-0

P02

3

observation

obs-p02-3

The source row for P02 supplies amount and duration, so PyMixEF derives a constant rate of 60 / 2 = 30.

7.5.2. 2. Inspect the canonical timeline

Two subject timelines showing dose, infusion-start, observation, and covariate events, including offset markers for same-time events at zero hours.

Canonical event timeline by subject. Event type is encoded by color and marker while time remains on the horizontal axis.

Interpretation. The timeline exposes both schedules and the same-time dose/observation pairs at zero. Small vertical offsets separate markers only for readability; they neither modify the time nor override canonical priority.

7.5.3. 3. Apply the same-time rule

The complete PyMixEF event priority is:

  1. reset;

  2. covariate update;

  3. infusion stop;

  4. bolus dose or infusion start;

  5. observation; and

  6. other.

Source position is the final stable tie-breaker. Therefore, a same-time observation in this convention sees the post-dose state. This is a scientific modeling choice that should be reconciled with the protocol, data derivation, and intended software comparison.

7.5.4. 4. Expand compact dosing instructions

Expansion is functional: it returns a new event table and does not mutate the source table.

expanded = events.expand_additional().expand_infusions()

assert [
    event.kind
    for event in expanded
    if event.subject_id == "P01" and event.time == 24
] == ["dose", "observation"]

The expanded schedule adds:

  • dose-p01:addl:1 at 12 h, sourced from dose-p01;

  • dose-p01:addl:2 at 24 h, sourced from dose-p01; and

  • inf-p02:infusion-stop at 2 h, sourced from inf-p02.

Faceted subject timelines separating original source records from generated additional-dose and infusion-stop records.

Canonical source rows and deterministic expansions. Circles indicate source rows and stars indicate generated rows.

Interpretation. P01 gains scheduled doses at 12 and 24 hours, while P02 gains the infusion stop at 2 hours. Every generated event has a deterministic row identifier and retains the originating source_row_id.

7.5.5. 5. Read provenance from the audit log

The audit describes transformations directly, so a reviewer does not need to infer them from row-count differences.

from collections import Counter

audit_counts = Counter(entry.code for entry in expanded.audit)
assert audit_counts["EVENT-ADDL-EXPANDED-001"] == 2
assert audit_counts["EVENT-INFUSION-EXPANDED-001"] == 1

generated_audit = [
    entry.to_dict()
    for entry in expanded.audit
    if entry.code
    in {
        "EVENT-ADDL-EXPANDED-001",
        "EVENT-INFUSION-EXPANDED-001",
    }
]

The saved audit counts are:

Audit code

Count

Meaning in this example

EVENT-CANONICALIZED-001

7

One entry for every source row

EVENT-ADDL-EXPANDED-001

2

Two generated P01 doses

EVENT-RATE-DERIVED-001

1

P02 rate derived from amount/duration

EVENT-INFUSION-EXPANDED-001

1

One generated P02 infusion stop

Horizontal bars showing seven canonicalization, two additional-dose expansion, one rate-derivation, and one infusion-expansion audit records.

Canonicalization and expansion audit actions. Machine-readable action counts summarize how the event table was constructed.

Interpretation. The seven canonicalization records match the seven source rows. The two additional-dose and one infusion-stop entries match the three generated rows, while the rate-derived entry records how the infusion input was resolved.

7.5.6. 6. Preserve missingness semantics

A missing dose amount is not automatically zero. PyMixEF distinguishes three states:

from pymixef.pharmacometrics import EventValidationError

unknown = canonicalize_events(
    [{"ID": "P03", "TIME": 0, "EVID": 1, "AMT_STATUS": "unknown"}]
)
zero = canonicalize_events(
    [{"ID": "P03", "TIME": 0, "EVID": 1, "AMT": 0}]
)
observation = canonicalize_events(
    [{"ID": "P03", "TIME": 1, "EVID": 0, "DV": 0.5}]
)

assert (unknown[0].amount, unknown[0].amount_status) == (None, "unknown")
assert (zero[0].amount, zero[0].amount_status) == (0.0, "recorded")
assert (observation[0].amount, observation[0].amount_status) == (
    None,
    "not-applicable",
)

try:
    canonicalize_events([{"ID": "P03", "TIME": 0, "EVID": 1}])
except EventValidationError as error:
    print(error)

An ambiguous dose without AMT is refused unless AMT_STATUS="unknown" is explicit. The saved message is:

dose records without AMT require AMT_STATUS='unknown'; use AMT=0 for a
recorded zero dose [row=0]

7.6. Key saved results

  • Canonical subjects: ("P01", "P02").

  • Original event-table length: 7.

  • Expanded event-table length: 10.

  • Generated-event count: 3.

  • P02 derived infusion rate: 30.0.

  • P01 additional-dose times: 12.0 and 24.0.

  • P02 generated infusion-stop time: 2.0.

  • P01 ordering at 24 h: ["dose", "observation"].

  • All expansion and same-time assertions passed.

  • Unknown dose: amount None, status unknown.

  • Recorded zero dose: amount 0.0, status recorded.

  • Observation: amount None, status not-applicable.

7.7. API map

Task

Public API

Result used here

Canonicalize records

pymixef.pharmacometrics.events.canonicalize_events()

Immutable pymixef.pharmacometrics.events.EventTable

Catch invalid event semantics

pymixef.pharmacometrics.events.EventValidationError

Explicit refusal

List subjects

pymixef.pharmacometrics.events.EventTable.subjects

Stable subject tuple

Iterate/index events

pymixef.pharmacometrics.events.EventTable sequence interface

pymixef.pharmacometrics.events.CanonicalEvent objects

Expand ADDL/II

pymixef.pharmacometrics.events.EventTable.expand_additional()

New event table

Expand finite infusions

pymixef.pharmacometrics.events.EventTable.expand_infusions()

Generated stop events

Select one subject

pymixef.pharmacometrics.events.EventTable.for_subject()

Subject-level event table

Inspect transformations

pymixef.pharmacometrics.events.EventTable.audit

pymixef.pharmacometrics.events.AuditEntry sequence

Serialize an audit row

pymixef.pharmacometrics.events.AuditEntry.to_dict()

Plain Python mapping

Return ordinary records

pymixef.pharmacometrics.events.EventTable.to_records()

List of mappings

Important CanonicalEvent fields used here are subject_id, time, kind, amount, rate, row_id, source_row_id, generated, and amount_status.

7.8. Exercises

  1. Add a reset-and-dose record and inspect its position relative to a same-time covariate update.

  2. Supply inconsistent AMT, RATE, and DUR values and study the refusal message.

  3. Use expanded.for_subject("P01") to create a subject-only table.

  4. Round-trip expanded.to_records() through canonicalize_events(...) and compare subject/time/event ordering.

  5. Add two observations at the same subject and time, then verify that source position remains the stable tie-breaker.

Interpretation boundaries

Use canonicalization as an auditable contract between source data and numerical modeling. Before simulation, confirm that the documented same-time convention, units, compartment mapping, and protocol derivations match the intended analysis. An explicitly unknown amount can be retained for review but must be resolved before it can drive a numerical state update. The current simulator also refuses steady-state and modeled or negative infusion-rate semantics rather than approximating them silently.