Scheduling Expedited Safety Report Deadlines in Python
An expedited safety report deadline is set by a single fact — the date a sponsor first held enough information to recognize a suspected unexpected serious adverse reaction — and by a single rule: 7 calendar days if the event was fatal or life-threatening, 15 calendar days for any other serious, unexpected reaction. Getting either the fact or the rule wrong produces a deadline that looks correct in the code and is wrong in an inspection. This walkthrough builds the calculator itself: parsing the awareness date correctly, applying the calendar-day clock in the right timezone, and emitting a due date and reminder schedule an inspector can trust. It is the concrete implementation behind Submission Deadline & Scheduling Automation, part of the Core Architecture & Regulatory Mapping for Clinical Trials domain, and every computed deadline is written to the append-only audit log so it can be reconstructed from the awareness date alone.
Why naive approaches fail
A first attempt at this calculator usually looks like due_date = aware_date + timedelta(days=7) run on whatever datetime object happened to be lying around. Several failure modes hide inside that one line:
- Naive datetimes with no timezone. A
datetimewithout atzinfohas no defensible meaning once a day boundary is involved — “day 7” depends entirely on which timezone’s midnight you mean, and a naive object cannot answer that question. - Arithmetic across a daylight-saving transition. Adding
timedelta(days=7)to a timezone-aware value that uses a fixed UTC offset (rather than a real IANA zone) silently produces a wall-clock time an hour off whenever the window crosses a spring-forward or fall-back boundary — invisible until someone checks the due date against a wall clock in that zone. - Treating the deadline as business days. There is no weekend or holiday extension for expedited safety reporting; a calculator that reuses a general-purpose “add N business days” helper built for other filings will compute a due date that is too late, and too late is a compliance failure, not a rounding error.
- Losing the original awareness timestamp. If only the computed due date is persisted and the awareness timestamp that produced it is discarded or overwritten, the deadline cannot be independently re-derived later — which is exactly what an inspector will ask you to do.
Architecture overview
The calculator is a short, pure pipeline: parse the awareness timestamp with its timezone, classify severity, add the calendar-day window, localize the result to the controlling timezone, and emit the due date alongside an audit record.
Setup and configuration
The zoneinfo module is standard library since Python 3.9, so no third-party timezone package is required. On some minimal Linux base images the IANA database itself is not installed system-wide, so add tzdata as a safety net.
pip install "tzdata>=2024.1"
Configuration — the authority’s controlling timezone and the two window lengths — is read from the environment so the same calculator serves multiple authorities without a code change:
"""Environment-driven configuration for the deadline calculator."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class DeadlineConfig:
authority_timezone: str # IANA key, e.g. "America/New_York"
fatal_window_days: int
other_serious_window_days: int
@classmethod
def from_env(cls) -> "DeadlineConfig":
def required(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
return cls(
authority_timezone=required("SAFETY_REPORT_AUTHORITY_TZ"),
fatal_window_days=int(os.environ.get("SAFETY_REPORT_FATAL_DAYS", "7")),
other_serious_window_days=int(os.environ.get("SAFETY_REPORT_OTHER_DAYS", "15")),
)
Defaulting the window lengths to 7 and 15 documents the ICH E2A baseline directly in code while still letting a stricter internal policy override it per environment.
Full working implementation
The calculator is deliberately small: one function to parse a timezone-aware awareness timestamp, one to classify severity into a window, one to compute the due date, and one to package the result with an audit record.
"""Compute an expedited safety report deadline from an awareness timestamp."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from enum import Enum
from zoneinfo import ZoneInfo
class SeverityTier(str, Enum):
FATAL_OR_LIFE_THREATENING = "fatal_or_life_threatening"
OTHER_SERIOUS = "other_serious"
class DeadlineError(ValueError):
"""Raised when an awareness timestamp cannot be safely used to compute a deadline."""
@dataclass(frozen=True)
class SafetyReportDeadline:
subject_id: str
severity: SeverityTier
aware_at: datetime
due_at: datetime
window_days: int
audit_hash: str
def parse_awareness_timestamp(raw: str, reporter_timezone: str) -> datetime:
"""Parse a naive local timestamp and attach the site's own IANA zone.
Raises DeadlineError if the zone key is unknown, rather than silently
falling back to UTC.
"""
try:
tz = ZoneInfo(reporter_timezone)
except Exception as exc:
raise DeadlineError(f"unknown timezone key: {reporter_timezone!r}") from exc
try:
naive = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
except ValueError as exc:
raise DeadlineError(f"unparseable awareness timestamp: {raw!r}") from exc
return naive.replace(tzinfo=tz)
def window_for(severity: SeverityTier, config: "DeadlineConfig") -> int:
if severity is SeverityTier.FATAL_OR_LIFE_THREATENING:
return config.fatal_window_days
return config.other_serious_window_days
def compute_deadline(
subject_id: str,
severity: SeverityTier,
aware_at: datetime,
config: "DeadlineConfig",
) -> SafetyReportDeadline:
if aware_at.tzinfo is None:
raise DeadlineError("aware_at must be timezone-aware")
authority_tz = ZoneInfo(config.authority_timezone)
aware_in_authority_tz = aware_at.astimezone(authority_tz)
window_days = window_for(severity, config)
due_date: date = (aware_in_authority_tz + timedelta(days=window_days)).date()
due_at = datetime.combine(due_date, time(23, 59, 59), tzinfo=authority_tz)
record = {
"subject_id": subject_id,
"severity": severity.value,
"aware_at": aware_at.isoformat(),
"due_at": due_at.isoformat(),
"window_days": window_days,
}
serialized = json.dumps(record, sort_keys=True, separators=(",", ":"))
audit_hash = hashlib.sha256(serialized.encode()).hexdigest()
return SafetyReportDeadline(
subject_id=subject_id,
severity=severity,
aware_at=aware_at,
due_at=due_at,
window_days=window_days,
audit_hash=audit_hash,
)
aware_in_authority_tz performs the timezone conversion before any day arithmetic runs, so the day boundary that matters is always the authority’s, not whichever zone the awareness event happened to be recorded in. The audit_hash binds the subject, severity, both timestamps, and the window length into one value, so a later dispute over “what was the deadline computed from” resolves to a hash comparison rather than a debate.
A small helper turns the result into reminder checkpoints, proportionally spaced so a 7-day and a 15-day window both leave meaningful lead time before the final escalation:
"""Derive reminder checkpoints from a computed deadline."""
from __future__ import annotations
def reminder_checkpoints(deadline: SafetyReportDeadline) -> tuple[datetime, ...]:
span = deadline.due_at - deadline.aware_at
return tuple(
deadline.aware_at + span * fraction
for fraction in (0.4, 0.7, 0.9)
)
def escalation_checkpoint(deadline: SafetyReportDeadline) -> datetime:
return deadline.due_at - timedelta(hours=24)
Validation and edge-case handling
Three edge cases account for most of the disputes that surface when a computed deadline is challenged after the fact:
- Awareness recorded exactly at a DST transition. Because the conversion goes through
astimezone()on a realZoneInfoobject rather than a fixed offset, an awareness timestamp recorded during the one-hour window that repeats or is skipped at a DST boundary still resolves to a single, correct instant —zoneinfodisambiguates using thefoldattribute internally, which a fixed-offset orpytz-based implementation cannot do reliably. - An awareness date near a month or year boundary.
date + timedelta(days=N)correctly rolls across month and year boundaries because it operates on the proleptic Gregorian calendar, so a report becoming aware on December 27 with a 7-day window correctly resolves into the following January without special-casing. - A subject whose site timezone differs from the authority’s timezone by a large offset. Converting to the authority’s zone before computing the date means a report that becomes aware late in the evening site-local time can land on a different authority-local calendar date than a naive read of the raw string would suggest — which is exactly the intended behavior, since the controlling clock is the authority’s, not the site’s.
Testing and verification
The test suite pins down the two properties that matter most: the calendar-day math is exactly 7 or 15 days regardless of weekends, and a DST transition inside the window does not shift the computed due date by an hour.
"""Tests for the expedited safety report deadline calculator."""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
@pytest.fixture
def config() -> DeadlineConfig:
return DeadlineConfig(
authority_timezone="America/New_York",
fatal_window_days=7,
other_serious_window_days=15,
)
def test_fatal_window_is_seven_calendar_days(config: DeadlineConfig) -> None:
aware_at = datetime(2026, 7, 10, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles"))
deadline = compute_deadline("SUBJ-001", SeverityTier.FATAL_OR_LIFE_THREATENING, aware_at, config)
assert deadline.due_at.date().isoformat() == "2026-07-17"
def test_other_serious_window_spans_a_weekend_without_extension(config: DeadlineConfig) -> None:
# July 10, 2026 is a Friday; the 15-day window crosses two weekends
# and must not be extended for either of them.
aware_at = datetime(2026, 7, 10, 9, 0, 0, tzinfo=ZoneInfo("America/New_York"))
deadline = compute_deadline("SUBJ-002", SeverityTier.OTHER_SERIOUS, aware_at, config)
assert deadline.due_at.date().isoformat() == "2026-07-25"
def test_dst_transition_inside_window_does_not_shift_due_date(config: DeadlineConfig) -> None:
# October 28, 2026 is still Eastern Daylight Time; the 15-day window
# crosses the November 1 fall-back transition into Eastern Standard
# Time, and the computed calendar date must still land exactly on day 15.
aware_at = datetime(2026, 10, 28, 20, 0, 0, tzinfo=ZoneInfo("America/New_York"))
deadline = compute_deadline("SUBJ-003", SeverityTier.OTHER_SERIOUS, aware_at, config)
assert deadline.due_at.date().isoformat() == "2026-11-12"
def test_naive_awareness_timestamp_is_rejected(config: DeadlineConfig) -> None:
naive = datetime(2026, 7, 10, 9, 0, 0)
with pytest.raises(DeadlineError):
compute_deadline("SUBJ-004", SeverityTier.FATAL_OR_LIFE_THREATENING, naive, config)
The DST test is the one worth defending in code review even though it looks unremarkable: it is a regression guard against exactly the class of bug a fixed-offset or pytz-based implementation would introduce silently, and it is cheap to keep once it exists.
FAQ
Why end-of-day rather than the same time of day N days later?
Ending the window at 23:59:59 in the authority’s timezone is the conservative reading of within N calendar days — it gives the full Nth day rather than cutting it off at whatever hour the awareness event happened to occur. A sponsor who submits at any point during day N, however late, is within the window; treating the deadline as the same wall-clock time N days later would incorrectly narrow it.
Does this calculator ever need business-day logic?
No. Expedited safety report deadlines run in calendar days with no weekend or holiday extension. Business-day adjustment belongs only to periodic, administrative filings, and mixing that logic into this calculator risks quietly extending a safety deadline it has no authority to extend.
What happens if the awareness timestamp is corrected after the deadline is already computed?
Treat the correction as a new awareness event and compute a new, distinct deadline linked back to the original rather than editing the existing record in place. Both the original and corrected deadlines should be retained in the audit log so the full sequence of what was known, and when, remains reconstructable.