"""Read and query calendar-dated disease-event arrays from prepare_event_dates.py.""" from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Iterable import numpy as np import pandas as pd REQUIRED_FIELDS = {"eid", "event_date", "token"} def load_event_dates(path: str | Path) -> np.ndarray: """Load and validate the structured ``.npy`` event array.""" events = np.load(path) if events.dtype.names is None or not REQUIRED_FIELDS.issubset(events.dtype.names): raise ValueError( "Expected a structured .npy with eid, event_date, token fields. " "Create it with prepare_event_dates.py." ) return events def load_token_labels(labels_file: str | Path) -> dict[int, str]: """Load token -> human-readable code using the project label convention.""" labels = {1: "CHECKUP"} with Path(labels_file).open(encoding="utf-8") as handle: for index, line in enumerate(handle): code = line.strip().split(" ", maxsplit=1)[0] if code: labels[index + 2] = code return labels @dataclass class EventDateIndex: """Small in-memory query wrapper for exposure-linkage and cohort scripts.""" events: np.ndarray token_labels: dict[int, str] | None = None @classmethod def from_files( cls, event_file: str | Path, labels_file: str | Path | None = None, ) -> "EventDateIndex": labels = load_token_labels(labels_file) if labels_file is not None else None return cls(load_event_dates(event_file), labels) def to_frame(self, events: np.ndarray | None = None) -> pd.DataFrame: """Convert records to a convenient, calendar-dated DataFrame.""" data = self.events if events is None else events frame = pd.DataFrame( { "eid": data["eid"].astype("int64"), "event_date": pd.to_datetime(data["event_date"]), "token": data["token"].astype("int32"), } ) if self.token_labels is not None: frame["label_code"] = frame["token"].map(self.token_labels).fillna("UNKNOWN") return frame.sort_values(["eid", "event_date", "token"], kind="stable").reset_index(drop=True) def for_eid(self, eid: int) -> pd.DataFrame: """Return every stored disease/death event for one participant.""" return self.to_frame(self.events[self.events["eid"] == int(eid)]) def between( self, start: str | pd.Timestamp, end: str | pd.Timestamp, *, eids: Iterable[int] | None = None, tokens: Iterable[int] | None = None, ) -> pd.DataFrame: """Query events in an inclusive calendar-date interval.""" start_day = np.datetime64(pd.Timestamp(start).date(), "D") end_day = np.datetime64(pd.Timestamp(end).date(), "D") mask = (self.events["event_date"] >= start_day) & (self.events["event_date"] <= end_day) if eids is not None: mask &= np.isin(self.events["eid"], list(eids)) if tokens is not None: mask &= np.isin(self.events["token"], list(tokens)) return self.to_frame(self.events[mask]) def anchors_before(self, eid: int, date: str | pd.Timestamp) -> pd.DataFrame: """Return a participant's event history strictly before an exposure anchor.""" day = np.datetime64(pd.Timestamp(date).date(), "D") mask = (self.events["eid"] == int(eid)) & (self.events["event_date"] < day) return self.to_frame(self.events[mask]) def first_event(self, token: int) -> pd.DataFrame: """Return each participant's first date for a requested token.""" selected = self.events[self.events["token"] == int(token)] # Arrays produced by prepare_event_dates.py are already deduplicated; # sorting makes this safe for externally produced compatible arrays too. order = np.lexsort((selected["event_date"], selected["eid"])) selected = selected[order] _, first = np.unique(selected["eid"], return_index=True) return self.to_frame(selected[first])