Files
stack/src/aco/express/readmissions.py
2026-02-08 17:41:28 -05:00

813 lines
27 KiB
Python

from __future__ import annotations
from datetime import timedelta
import narwhals as nw
from narwhals.typing import FrameT
@nw.narwhalify
def int_encounter(core__encounter: FrameT) -> FrameT:
"""Build readmissions._int_encounter
Filters core.encounter to acute inpatient only, then selects and renames
columns for the readmissions pipeline.
Operations: filter, select
Dependencies: core.encounter
"""
return core__encounter.filter(nw.col("encounter_type") == "acute inpatient").select(
nw.col("encounter_id"),
nw.col("person_id"),
nw.col("encounter_start_date").alias("admit_date"),
nw.col("encounter_end_date").alias("discharge_date"),
nw.col("discharge_disposition_code"),
nw.col("facility_id"),
nw.col("drg_code_type"),
nw.col("drg_code"),
nw.col("paid_amount"),
nw.col("primary_diagnosis_code"),
nw.col("encounter_source_type"),
nw.col("data_source"),
)
@nw.narwhalify
def int_encounter_data_quality(
readmissions___int_encounter_with_ccs: FrameT,
readmissions___int_encounter_overlap: FrameT,
terminology__discharge_disposition: FrameT,
terminology__ms_drg: FrameT,
terminology__apr_drg: FrameT,
) -> FrameT:
"""Build readmissions._int_encounter_data_quality
Joins encounter data with terminology lookups to compute data quality flags,
then derives a disqualified_encounter_flag from the union of all issues.
Operations: join, select (CASE WHEN flags)
Dependencies: readmissions._int_encounter_with_ccs,
readmissions._int_encounter_overlap,
terminology.discharge_disposition, terminology.ms_drg,
terminology.apr_drg
"""
# Find encounters that lost the overlap "best encounter" selection
# Overlap losers: encounters not selected as best
overlap_losers = (
readmissions___int_encounter_overlap.filter(nw.col("is_best_encounter") == 0)
.select("encounter_id")
.unique()
.with_columns(nw.lit(1).alias("_overlap"))
)
# Join encounter_with_ccs to terminology lookups
aa = readmissions___int_encounter_with_ccs
result = aa.join(
terminology__discharge_disposition,
on=["discharge_disposition_code"],
how="left",
suffix="_dd",
)
ms = terminology__ms_drg.with_columns(nw.lit(1).alias("_ms_valid"))
result = result.join(
ms,
left_on=["drg_code"],
right_on=["ms_drg_code"],
how="left",
suffix="_ms",
)
apr = terminology__apr_drg.with_columns(nw.lit(1).alias("_apr_valid"))
result = result.join(
apr,
left_on=["drg_code"],
right_on=["apr_drg_code"],
how="left",
suffix="_apr",
)
result = result.join(overlap_losers, on=["encounter_id"], how="left")
# Compute data quality flags
result = result.with_columns(
nw.when(nw.col("admit_date").is_null())
.then(1)
.otherwise(0)
.alias("missing_admit_date_flag"),
nw.when(nw.col("discharge_date").is_null())
.then(1)
.otherwise(0)
.alias("missing_discharge_date_flag"),
nw.when(nw.col("admit_date") > nw.col("discharge_date"))
.then(1)
.otherwise(0)
.alias("admit_after_discharge_flag"),
nw.when(nw.col("discharge_disposition_code").is_null())
.then(1)
.otherwise(0)
.alias("missing_discharge_disposition_code_flag"),
nw.when(
~nw.col("discharge_disposition_code").is_null()
& nw.col("discharge_disposition_description").is_null()
)
.then(1)
.otherwise(0)
.alias("invalid_discharge_disposition_code_flag"),
nw.when(nw.col("primary_diagnosis_code").is_null())
.then(1)
.otherwise(0)
.alias("missing_primary_diagnosis_flag"),
nw.when(nw.col("valid_primary_diagnosis_code_flag") == 0)
.then(1)
.otherwise(0)
.alias("invalid_primary_diagnosis_code_flag"),
nw.when(nw.col("ccs_diagnosis_category").is_null())
.then(1)
.otherwise(0)
.alias("no_diagnosis_ccs_flag"),
nw.col("ccs_diagnosis_category").alias("diagnosis_ccs"),
nw.when(~nw.col("_overlap").is_null())
.then(1)
.otherwise(0)
.alias("overlaps_with_another_encounter_flag"),
nw.when(nw.col("drg_code").is_null())
.then(1)
.otherwise(0)
.alias("missing_drg_flag"),
nw.when(nw.col("_ms_valid").is_null() & nw.col("_apr_valid").is_null())
.then(1)
.otherwise(0)
.alias("invalid_drg_flag"),
)
# Derive disqualified_encounter_flag from any issue flag being 1
result = result.with_columns(
nw.when(
(nw.col("missing_admit_date_flag") == 1)
| (nw.col("missing_discharge_date_flag") == 1)
| (nw.col("admit_after_discharge_flag") == 1)
| (nw.col("missing_discharge_disposition_code_flag") == 1)
| (nw.col("invalid_discharge_disposition_code_flag") == 1)
| (nw.col("missing_primary_diagnosis_flag") == 1)
| (nw.col("invalid_primary_diagnosis_code_flag") == 1)
| (nw.col("no_diagnosis_ccs_flag") == 1)
| (nw.col("overlaps_with_another_encounter_flag") == 1)
| (nw.col("missing_drg_flag") == 1)
| (nw.col("invalid_drg_flag") == 1),
)
.then(1)
.otherwise(0)
.alias("disqualified_encounter_flag")
)
return result.select(
"encounter_id",
"diagnosis_ccs",
"disqualified_encounter_flag",
"missing_admit_date_flag",
"missing_discharge_date_flag",
"admit_after_discharge_flag",
"missing_discharge_disposition_code_flag",
"invalid_discharge_disposition_code_flag",
"missing_primary_diagnosis_flag",
"invalid_primary_diagnosis_code_flag",
"no_diagnosis_ccs_flag",
"overlaps_with_another_encounter_flag",
"missing_drg_flag",
"invalid_drg_flag",
)
@nw.narwhalify
def int_encounter_overlap(readmissions___int_encounter: FrameT) -> FrameT:
"""Build readmissions._int_encounter_overlap
Detects overlapping encounters per person, groups them, ranks by priority,
and selects the best encounter per overlap group.
Operations: select, window
Dependencies: readmissions._int_encounter
NOTE: The full overlap detection (self-join WHERE admit ranges intersect)
and window-based ranking require imperative frame operations that go beyond
simple narwhals expressions. This function computes the derived columns
that can be expressed declaratively; the overlap grouping and ranking
should be implemented as a multi-step pipeline by the caller.
"""
enc = readmissions___int_encounter
# Compute derived columns
days_diff = (
(
nw.col("discharge_date").cast(nw.Datetime)
- nw.col("admit_date").cast(nw.Datetime)
).dt.total_seconds()
/ 86400
).cast(nw.Int64)
enhanced = enc.with_columns(
nw.when(days_diff >= 1)
.then(days_diff)
.otherwise(1)
.alias("actual_length_of_stay"),
nw.when(
nw.col("encounter_source_type")
.fill_null("")
.str.to_uppercase()
.str.strip_chars()
== "CLAIM"
)
.then(1)
.otherwise(2)
.alias("source_type_priority"),
)
enhanced = enhanced.with_columns(
(
nw.when(
~nw.col("discharge_disposition_code").is_null()
& (nw.col("discharge_disposition_code").str.strip_chars() != "")
& (nw.col("discharge_disposition_code").str.strip_chars() != "00")
)
.then(1)
.otherwise(0)
+ nw.when(
~nw.col("drg_code_type").is_null()
& (nw.col("drg_code_type").str.strip_chars() != "")
)
.then(1)
.otherwise(0)
+ nw.when(
~nw.col("drg_code").is_null()
& (nw.col("drg_code").str.strip_chars() != "")
& ~nw.col("drg_code").str.strip_chars().is_in(["998", "999"])
)
.then(1)
.otherwise(0)
+ nw.when(~nw.col("paid_amount").is_null()).then(1).otherwise(0)
+ nw.when(
~nw.col("primary_diagnosis_code").is_null()
& (nw.col("primary_diagnosis_code").str.strip_chars() != "")
)
.then(1)
.otherwise(0)
).alias("completeness_score")
)
# Self-join to find overlapping encounters
e2 = enhanced.select(
nw.col("person_id").alias("_p2"),
nw.col("encounter_id").alias("_e2"),
nw.col("admit_date").alias("_a2"),
nw.col("discharge_date").alias("_d2"),
)
pairs = enhanced.join(
e2,
left_on=["person_id"],
right_on=["_p2"],
how="inner",
)
pairs = pairs.filter(
(nw.col("encounter_id") != nw.col("_e2"))
& (nw.col("admit_date") <= nw.col("_d2"))
& (nw.col("discharge_date") >= nw.col("_a2"))
)
has_overlap = (
pairs.select("encounter_id")
.unique()
.with_columns(nw.lit(1).alias("has_overlaps"))
)
# Overlap group: min of peer encounter_ids
grp = pairs.group_by("encounter_id").agg(nw.col("_e2").min().alias("_min_peer"))
grp = grp.with_columns(
nw.when(nw.col("_min_peer") < nw.col("encounter_id"))
.then(nw.col("_min_peer"))
.otherwise(nw.col("encounter_id"))
.alias("overlap_group_id")
).select("encounter_id", "overlap_group_id")
# Join back
result = enhanced.join(has_overlap, on=["encounter_id"], how="left").join(
grp, on=["encounter_id"], how="left"
)
result = result.with_columns(
nw.col("has_overlaps").fill_null(0),
nw.col("overlap_group_id").fill_null(nw.col("encounter_id")),
)
# Rank within overlap group
result = result.sort(
"person_id",
"overlap_group_id",
"source_type_priority",
"encounter_id",
)
result = result.with_columns(
nw.col("encounter_id")
.rank(method="ordinal")
.over("person_id", "overlap_group_id")
.alias("encounter_rank_in_group")
)
result = result.with_columns(
nw.when(nw.col("encounter_rank_in_group") == 1)
.then(1)
.otherwise(0)
.alias("is_best_encounter"),
nw.when(
(nw.col("encounter_rank_in_group") == 1) & (nw.col("has_overlaps") == 1)
)
.then(nw.lit("Selected as best among overlapping"))
.otherwise(
nw.when(
(nw.col("encounter_rank_in_group") == 1) & (nw.col("has_overlaps") == 0)
)
.then(nw.lit("No overlapping encounters"))
.otherwise(nw.lit("Not selected - better exists"))
)
.alias("selection_reason"),
)
return result.select(
"encounter_id",
"person_id",
"admit_date",
"discharge_date",
"actual_length_of_stay",
"source_type_priority",
"completeness_score",
"overlap_group_id",
"has_overlaps",
"encounter_rank_in_group",
"is_best_encounter",
"selection_reason",
)
@nw.narwhalify
def int_encounter_specialty_cohort(
readmissions___int_encounter: FrameT,
readmissions___int_encounter_with_ccs: FrameT,
readmissions___int_procedure_ccs: FrameT,
readmissions___value_set_specialty_cohort: FrameT,
readmissions___value_set_surgery_gynecology_cohort: FrameT,
) -> FrameT:
"""Build readmissions._int_encounter_specialty_cohort
Assigns each encounter to a specialty cohort based on procedure/diagnosis CCS
codes. Surgery/Gynecology is highest priority (rank 1), then Cardiorespiratory,
Cardiovascular, Neurology, and Medicine as default.
Operations: join, group_by, select
Dependencies: readmissions._int_encounter, readmissions._int_encounter_with_ccs,
readmissions._int_procedure_ccs,
readmissions._value_set_specialty_cohort,
readmissions._value_set_surgery_gynecology_cohort
"""
# Surgery/Gynecology encounters from procedure codes
surg_from_icd = readmissions___int_procedure_ccs.join(
readmissions___value_set_surgery_gynecology_cohort,
left_on=["procedure_code"],
right_on=["icd_10_pcs"],
how="inner",
).select(nw.col("encounter_id"), nw.lit(1).alias("c_rank"))
surg_from_ccs = readmissions___int_procedure_ccs.join(
readmissions___value_set_specialty_cohort.filter(
nw.col("specialty_cohort") == "Surgery/Gynecology"
),
left_on=["ccs_procedure_category"],
right_on=["ccs"],
how="inner",
).select(nw.col("encounter_id"), nw.lit(1).alias("c_rank"))
# Diagnosis-based cohorts
diag_cohorts = (
readmissions___int_encounter_with_ccs.join(
readmissions___value_set_specialty_cohort.filter(
nw.col("procedure_or_diagnosis") == "Diagnosis"
),
left_on=["ccs_diagnosis_category"],
right_on=["ccs"],
how="inner",
)
.with_columns(
nw.when(nw.col("specialty_cohort") == "Cardiorespiratory")
.then(2)
.otherwise(
nw.when(nw.col("specialty_cohort") == "Cardiovascular")
.then(3)
.otherwise(
nw.when(nw.col("specialty_cohort") == "Neurology")
.then(4)
.otherwise(5)
)
)
.alias("c_rank")
)
.select(nw.col("encounter_id"), nw.col("c_rank"))
)
# Combine all cohort assignments, pick min rank per encounter
all_cohorts = nw.concat([surg_from_icd, surg_from_ccs, diag_cohorts])
main_cohort = all_cohorts.group_by("encounter_id").agg(
nw.col("c_rank").min().alias("main_c_rank")
)
# Map rank back to cohort name, default to Medicine
result = readmissions___int_encounter.join(
main_cohort, on=["encounter_id"], how="left"
).with_columns(
nw.when(nw.col("main_c_rank") == 1)
.then(nw.lit("Surgery/Gynecology"))
.otherwise(
nw.when(nw.col("main_c_rank") == 2)
.then(nw.lit("Cardiorespiratory"))
.otherwise(
nw.when(nw.col("main_c_rank") == 3)
.then(nw.lit("Cardiovascular"))
.otherwise(
nw.when(nw.col("main_c_rank") == 4)
.then(nw.lit("Neurology"))
.otherwise(nw.lit("Medicine"))
)
)
)
.alias("specialty_cohort")
)
return result.select("encounter_id", "specialty_cohort")
@nw.narwhalify
def int_encounter_with_ccs(
readmissions___int_encounter: FrameT,
readmissions___value_set_icd_10_cm_to_ccs: FrameT,
terminology__icd_10_cm: FrameT,
) -> FrameT:
"""Build readmissions._int_encounter_with_ccs
Joins encounters with ICD-10-CM terminology to validate diagnosis codes,
and with CCS mapping to get the diagnosis category.
Operations: join, select (CASE WHEN)
Dependencies: readmissions._int_encounter, readmissions._value_set_icd_10_cm_to_ccs,
terminology.icd_10_cm
"""
icd_cm = terminology__icd_10_cm.with_columns(nw.lit(1).alias("_icd_valid"))
result = readmissions___int_encounter.join(
icd_cm,
left_on=["primary_diagnosis_code"],
right_on=["icd_10_cm"],
how="left",
).join(
readmissions___value_set_icd_10_cm_to_ccs,
left_on=["primary_diagnosis_code"],
right_on=["icd_10_cm"],
how="left",
suffix="_ccs",
)
return result.select(
nw.col("encounter_id"),
nw.col("person_id"),
nw.col("admit_date"),
nw.col("discharge_date"),
nw.col("discharge_disposition_code"),
nw.col("facility_id"),
nw.col("drg_code_type"),
nw.col("drg_code"),
nw.col("paid_amount"),
nw.col("primary_diagnosis_code"),
nw.when(~nw.col("_icd_valid").is_null())
.then(1)
.otherwise(0)
.alias("valid_primary_diagnosis_code_flag"),
nw.col("ccs_diagnosis_category"),
)
@nw.narwhalify
def int_exclusion(
readmissions___int_encounter_with_ccs: FrameT,
readmissions___value_set_exclusion_ccs_diagnosis_category: FrameT,
) -> FrameT:
"""Build readmissions._int_exclusion
Returns distinct encounter_ids whose CCS diagnosis category appears in the
exclusion value set (semi-join).
Operations: join (semi), select
Dependencies: readmissions._int_encounter_with_ccs,
readmissions._value_set_exclusion_ccs_diagnosis_category
"""
exclusion_categories = (
readmissions___value_set_exclusion_ccs_diagnosis_category.select(
"ccs_diagnosis_category"
).unique()
)
return (
readmissions___int_encounter_with_ccs.filter(
~nw.col("ccs_diagnosis_category").is_null()
)
.join(exclusion_categories, on=["ccs_diagnosis_category"], how="inner")
.select("encounter_id")
.unique()
)
@nw.narwhalify
def int_index_admission(
readmissions___int_encounter: FrameT,
readmissions___int_index_time_requirement: FrameT,
readmissions___int_index_discharge_requirement: FrameT,
readmissions___int_exclusion: FrameT,
) -> FrameT:
"""Build readmissions._int_index_admission
An index admission passes both the time and discharge requirements and is
NOT in the exclusion list (anti-join on exclusion).
Operations: join, filter (anti-join)
Dependencies: readmissions._int_encounter, readmissions._int_index_time_requirement,
readmissions._int_index_discharge_requirement,
readmissions._int_exclusion
"""
excl = (
readmissions___int_exclusion.select("encounter_id")
.unique()
.with_columns(nw.lit(1).alias("_excl"))
)
result = (
readmissions___int_encounter.join(
readmissions___int_index_time_requirement.select("encounter_id"),
on=["encounter_id"],
how="inner",
)
.join(
readmissions___int_index_discharge_requirement.select("encounter_id"),
on=["encounter_id"],
how="inner",
)
.join(excl, on=["encounter_id"], how="left")
)
return result.filter(nw.col("_excl").is_null()).select("encounter_id").unique()
@nw.narwhalify
def int_index_discharge_requirement(readmissions___int_encounter: FrameT) -> FrameT:
"""Build readmissions._int_index_discharge_requirement
Excludes encounters with invalid discharge disposition codes (02, 07, 20)
via anti-join pattern.
Operations: filter (anti-join)
Dependencies: readmissions._int_encounter
"""
invalid_ids = (
readmissions___int_encounter.filter(
nw.col("discharge_disposition_code").is_in(["02", "07", "20"])
)
.select("encounter_id")
.unique()
)
invalid_ids = invalid_ids.with_columns(nw.lit(1).alias("_invalid"))
result = readmissions___int_encounter.join(
invalid_ids, on=["encounter_id"], how="left"
)
return result.filter(nw.col("_invalid").is_null()).select("encounter_id")
@nw.narwhalify
def int_index_time_requirement(readmissions___int_encounter: FrameT) -> FrameT:
"""Build readmissions._int_index_time_requirement
Keeps encounters whose discharge_date is at least 30 days before the
latest discharge in the dataset (so there's a window to observe readmission).
Operations: filter
Dependencies: readmissions._int_encounter
"""
# discharge_date <= max(discharge_date) - 30 days
max_discharge = readmissions___int_encounter.select(
nw.col("discharge_date").max().alias("max_discharge")
)
cutoff = readmissions___int_encounter.join(max_discharge, how="cross")
return cutoff.filter(
nw.col("discharge_date") <= nw.col("max_discharge") - nw.lit(timedelta(days=30))
).select("encounter_id")
@nw.narwhalify
def int_planned_encounter(
readmissions___int_encounter_with_ccs: FrameT,
readmissions___int_procedure_ccs: FrameT,
readmissions___value_set_acute_diagnosis_ccs: FrameT,
readmissions___value_set_acute_diagnosis_icd_10_cm: FrameT,
readmissions___value_set_always_planned_ccs_diagnosis_category: FrameT,
readmissions___value_set_always_planned_ccs_procedure_category: FrameT,
readmissions___value_set_potentially_planned_ccs_procedure_category: FrameT,
readmissions___value_set_potentially_planned_icd_10_pcs: FrameT,
) -> FrameT:
"""Build readmissions._int_planned_encounter
Identifies encounters that are considered "planned" readmissions using
CMS methodology: always-planned procedures, always-planned diagnoses,
and potentially-planned procedures that don't have an acute diagnosis.
Operations: join, filter, union
Dependencies: readmissions._int_encounter_with_ccs, readmissions._int_procedure_ccs,
readmissions._value_set_* (6 value sets)
"""
# Always planned: procedure CCS category in always-planned list
always_planned_px = (
readmissions___int_procedure_ccs.join(
readmissions___value_set_always_planned_ccs_procedure_category,
on=["ccs_procedure_category"],
how="inner",
)
.select("encounter_id")
.unique()
)
# Always planned: diagnosis CCS category in always-planned list
always_planned_dx = (
readmissions___int_encounter_with_ccs.join(
readmissions___value_set_always_planned_ccs_diagnosis_category,
on=["ccs_diagnosis_category"],
how="inner",
)
.select("encounter_id")
.unique()
)
# Potentially planned: procedure CCS or ICD-10-PCS in potentially-planned list
pot_px_ccs = readmissions___int_procedure_ccs.join(
readmissions___value_set_potentially_planned_ccs_procedure_category,
on=["ccs_procedure_category"],
how="inner",
).select("encounter_id")
pot_px_icd = readmissions___int_procedure_ccs.join(
readmissions___value_set_potentially_planned_icd_10_pcs,
left_on=["procedure_code"],
right_on=["icd_10_pcs"],
how="inner",
).select("encounter_id")
potentially_planned = nw.concat([pot_px_ccs, pot_px_icd]).unique()
# Acute encounters: those with an acute diagnosis (ICD-10-CM or CCS)
acute_icd = readmissions___int_encounter_with_ccs.join(
readmissions___value_set_acute_diagnosis_icd_10_cm,
left_on=["primary_diagnosis_code"],
right_on=["icd_10_cm"],
how="inner",
).select("encounter_id")
acute_ccs = readmissions___int_encounter_with_ccs.join(
readmissions___value_set_acute_diagnosis_ccs,
on=["ccs_diagnosis_category"],
how="inner",
).select("encounter_id")
acute_encounters = nw.concat([acute_icd, acute_ccs]).unique()
# Potentially planned minus acute = actually planned
acute_marked = acute_encounters.with_columns(nw.lit(1).alias("_acute"))
pot_actually_planned = (
potentially_planned.join(acute_marked, on=["encounter_id"], how="left")
.filter(nw.col("_acute").is_null())
.select("encounter_id")
)
return nw.concat(
[always_planned_px, always_planned_dx, pot_actually_planned]
).unique()
@nw.narwhalify
def int_procedure_ccs(
core__procedure: FrameT,
readmissions___value_set_icd_10_pcs_to_ccs: FrameT,
terminology__icd_10_pcs: FrameT,
) -> FrameT:
"""Build readmissions._int_procedure_ccs
Filters core.procedure to ICD-10-PCS, joins with terminology
for validation and CCS mapping for procedure categories.
Operations: filter, join, select
Dependencies: core.procedure,
readmissions._value_set_icd_10_pcs_to_ccs,
terminology.icd_10_pcs
"""
procs = core__procedure.filter(nw.col("normalized_code_type") == "icd-10-pcs")
icd_pcs = terminology__icd_10_pcs.with_columns(nw.lit(1).alias("_pcs_valid"))
result = procs.join(
icd_pcs,
left_on=["normalized_code"],
right_on=["icd_10_pcs"],
how="left",
)
result = result.join(
readmissions___value_set_icd_10_pcs_to_ccs,
left_on=["normalized_code"],
right_on=["icd_10_pcs"],
how="left",
suffix="_ccs",
)
return result.select(
nw.col("encounter_id"),
nw.col("normalized_code").alias("procedure_code"),
nw.when(~nw.col("_pcs_valid").is_null())
.then(1)
.otherwise(0)
.alias("valid_icd_10_pcs_flag"),
nw.col("ccs_procedure_category"),
)
@nw.narwhalify
def int_readmission_crude(
readmissions___int_encounter: FrameT,
readmissions___int_encounter_overlap: FrameT,
) -> FrameT:
"""Build readmissions._int_readmission_crude
Sequences valid, non-overlapping encounters per person by admit date,
then detects readmissions by joining consecutive encounters and checking
if the gap is <= 30 days.
Operations: filter, window, join (self), select
Dependencies: readmissions._int_encounter, readmissions._int_encounter_overlap
"""
# Anti-join: exclude overlap losers
losers = (
readmissions___int_encounter_overlap.filter(nw.col("is_best_encounter") == 0)
.select("encounter_id")
.unique()
.with_columns(nw.lit(1).alias("_loser"))
)
valid = (
readmissions___int_encounter.filter(
~nw.col("admit_date").is_null()
& ~nw.col("discharge_date").is_null()
& (nw.col("admit_date") <= nw.col("discharge_date"))
)
.join(losers, on=["encounter_id"], how="left")
.filter(nw.col("_loser").is_null())
.select(
"encounter_id",
"person_id",
"admit_date",
"discharge_date",
)
)
# Sequence encounters per person by admit_date
valid = valid.with_columns(
nw.col("admit_date")
.rank(method="ordinal")
.over("person_id")
.alias("encounter_seq")
)
# Self-join: current encounter (aa) to next (bb)
valid = valid.with_columns((nw.col("encounter_seq") + 1).alias("_next_seq"))
bb = valid.select(
nw.col("person_id").alias("_p"),
nw.col("encounter_seq").alias("_seq"),
nw.col("encounter_id").alias("_next_enc"),
nw.col("admit_date").alias("_next_admit"),
)
result = valid.join(
bb,
left_on=["person_id", "_next_seq"],
right_on=["_p", "_seq"],
how="left",
)
# Compute readmission flags
days_to = (
(
nw.col("_next_admit").cast(nw.Datetime)
- nw.col("discharge_date").cast(nw.Datetime)
).dt.total_seconds()
/ 86400
).cast(nw.Int64)
result = result.with_columns(
nw.when(~nw.col("_next_enc").is_null())
.then(1)
.otherwise(0)
.alias("had_readmission_flag"),
days_to.alias("days_to_readmit"),
nw.when(days_to <= 30).then(1).otherwise(0).alias("readmit_30_flag"),
)
return result.select(
"encounter_id",
"person_id",
"admit_date",
"discharge_date",
"had_readmission_flag",
"days_to_readmit",
"readmit_30_flag",
)