CoolFace
Apppublic

prashant-9457/my-openenv-task

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
env.py681 linesDownload Raw Back to root
1"""2ICU Resource Allocation — OpenEnv Environment3==============================================4A real-world environment modelling a 20-bed ICU in a 500-bed Indian tertiary-5care hospital.  An AI agent acts as the ICU charge-coordinator, deciding every630 minutes how to allocate beds, staff and equipment across an incoming stream7of critically ill patients.8 9Clinical grounding10------------------11- Patient severity measured by the SOFA score (Sequential Organ Failure12  Assessment, range 0-24), the gold standard triage tool used in ICUs globally.13- Nurse : patient ratios follow NABH (National Accreditation Board for Hospitals)14  guidelines — 1 : 2 for ICU.15- Bed turnover time (cleaning + preparation) modelled at 45-90 min, matching16  published Indian hospital data.17- Patient arrival follows a non-homogeneous Poisson process with higher rates18  during 08-12 h and 20-24 h (documented admission peaks).19- Equipment (ventilators, monitors, dialysis) tracked against real 500-bed20  tertiary-care inventories.21- Costs in INR, calibrated to CGHS package rates (Central Govt Health Scheme).22 23Action space  (Discrete 7)24--------------------------250  HOLD            – Observe; no allocation change this step.261  ADMIT_CRITICAL  – Admit the highest-SOFA patient from the waiting queue.272  ADMIT_FIFO      – Admit the longest-waiting patient from the queue.283  TRANSFER_OUT    – Transfer the most stable current ICU patient to step-down.294  CALL_EXTRA_NURSE– Arrange an overtime nurse for this shift (₹1 200 premium).305  SPECIALIST_CONSULT – Request urgent specialist consult for the sickest31                        current patient (₹3 500, reduces mortality risk).326  EXPEDITE_BED    – Pay porter/housekeeping overtime to clean next bed faster33                      (₹600, cuts turnover time by ~30 min).34 35Observation space (23 fields)36------------------------------37See _build_obs() for full description with units and ranges.38 39Reward  (partial progress at every step, no sparse end-of-episode)40------41See _calculate_reward() for breakdown.42"""43 44import math45import random46from dataclasses import dataclass, field47from typing import Optional48 49 50# ─────────────────────────────────────────────────────────────────────────────51# Data structures52# ─────────────────────────────────────────────────────────────────────────────53 54@dataclass55class Patient:56    """Represents a single patient."""57    pid: int58    sofa: float          # 0-24  (Sequential Organ Failure Assessment score)59    needs_ventilator: bool60    needs_dialysis: bool61    arrival_step: int    # Step when patient arrived in queue62    admitted_step: Optional[int] = None63    los_steps: int = 0   # Expected length of stay in steps (each step=30 min)64    mortality_risk: float = 0.0   # 0-1  (derived from SOFA)65 66    @staticmethod67    def sofa_to_mortality(sofa: float) -> float:68        """69        Approximate ICU mortality from SOFA score.70        Based on: Ferreira et al., JAMA 2001 — SOFA score as predictor of ICU outcome.71        """72        # SOFA 0-6: ~10%, 7-9: ~21%, 10-12: ~33%, 13-14: ~50%, 15-24: ~95%73        breakpoints = [(6, 0.10), (9, 0.21), (12, 0.33), (14, 0.50), (24, 0.95)]74        for threshold, risk in breakpoints:75            if sofa <= threshold:76                return risk77        return 0.9578 79    def __post_init__(self):80        self.mortality_risk = self.sofa_to_mortality(self.sofa)81 82 83@dataclass84class Bed:85    """ICU bed state."""86    bed_id: int87    patient: Optional[Patient] = None88    turnover_steps_remaining: int = 0   # >0 means bed being cleaned89 90    @property91    def is_available(self) -> bool:92        return self.patient is None and self.turnover_steps_remaining == 093 94    @property95    def is_occupied(self) -> bool:96        return self.patient is not None97 98    @property99    def in_turnover(self) -> bool:100        return self.patient is None and self.turnover_steps_remaining > 0101 102 103# ─────────────────────────────────────────────────────────────────────────────104# Main environment105# ─────────────────────────────────────────────────────────────────────────────106 107class ICUEnv:108    """109    OpenEnv-compliant ICU Resource Allocation environment.110 111    Each step = 30 minutes of real time.112    One episode = 48 steps = 24 hours (one full ICU duty cycle).113    """114 115    # ── Hospital configuration (typical 500-bed tertiary care, India) ────────116    TOTAL_ICU_BEDS      = 20117    TOTAL_VENTILATORS   = 12118    TOTAL_DIALYSIS      = 4119    TOTAL_MONITORS      = 20   # 1 per bed120 121    # Staff baseline per shift122    BASE_NURSES_DAY     = 10   # 1:2 ratio for 20 beds123    BASE_NURSES_NIGHT   = 8    # slightly reduced night staffing124    BASE_DOCTORS        = 2    # intensivists on call125 126    # Financial (INR, calibrated to CGHS 2023 rates)127    DAILY_BUDGET_INR    = 150_000   # ₹1.5 lakh daily ICU operating budget128    ICU_BED_COST_STEP   = 3_125     # ₹3 125 per bed per step (₹1.5L / 48 steps / ~1 bed)129    OVERTIME_NURSE_COST = 1_200     # Per shift overtime premium130    SPECIALIST_COST     = 3_500     # Specialist consult fee131    EXPEDITE_BED_COST   = 600       # Housekeeping overtime for fast bed prep132 133    # Clinical thresholds134    SAFE_NURSE_RATIO    = 2.0       # Max patients per nurse (NABH standard)135    CRITICAL_SOFA       = 11        # SOFA ≥ 11 → critical, time-sensitive136    TRANSFER_SOFA_MAX   = 6         # SOFA ≤ 6 → eligible for step-down transfer137    MAX_QUEUE_WAIT_STEPS = 4        # >4 steps (2h) wait for critical → outcome worsens138 139    # Time-to-admission mortality penalty scaling140    # Every 30-min delay for critical patient increases mortality risk by ~3%141    DELAY_MORTALITY_INCREMENT = 0.03142 143    MAX_STEPS = 48144 145    def __init__(self, seed: int = 42):146        self.seed = seed147        self._rng = random.Random(seed)148        self._step = 0149        self._pid_counter = 0150        self.reset()151 152    # ─────────────────────────────────────────────────────────────────────153    # OpenEnv API154    # ─────────────────────────────────────────────────────────────────────155 156    def reset(self) -> dict:157        """Reset to beginning of a fresh 24-hour duty cycle."""158        self._rng = random.Random(self.seed)159        self._step = 0160        self._pid_counter = 0161        self._hour = 8.0   # Duty cycle starts at 08:00162 163        # Beds164        self._beds = [Bed(bed_id=i) for i in range(self.TOTAL_ICU_BEDS)]165 166        # Pre-populate ~60% bed occupancy at start of shift (realistic handover)167        initial_patients = int(self.TOTAL_ICU_BEDS * 0.60)168        for i in range(initial_patients):169            p = self._generate_patient(is_initial=True)170            self._beds[i].patient = p171 172        # Queues and tracking173        self._queue: list[Patient] = []174        self._discharged: list[Patient] = []175        self._deaths_in_queue: int = 0176        self._adverse_events: int = 0177        self._admissions_today: int = 0178        self._transfers_today: int = 0179 180        # Staff181        self._extra_nurses_called: int = 0182        self._specialist_consults: int = 0183 184        # Equipment185        self._ventilators_in_use: int = sum(186            1 for b in self._beds if b.is_occupied and b.patient.needs_ventilator187        )188        self._dialysis_in_use: int = sum(189            1 for b in self._beds if b.is_occupied and b.patient.needs_dialysis190        )191 192        # Budget193        self._budget_remaining = self.DAILY_BUDGET_INR194        self._cost_this_step = 0.0195 196        # Outcome tracking197        self._mortality_risks_avoided = 0.0198        self._total_sofa_admitted = 0.0199        self._wait_violations = 0200 201        # Generate initial queue (2-5 waiting patients at shift start)202        for _ in range(self._rng.randint(2, 5)):203            self._queue.append(self._generate_patient())204 205        return self._build_obs()206 207    def step(self, action: int) -> tuple[dict, float, bool, dict]:208        """209        Apply action, simulate 30 minutes, return (obs, reward, done, info).210 211        Actions:212          0 HOLD213          1 ADMIT_CRITICAL  – admit highest-SOFA patient214          2 ADMIT_FIFO      – admit longest-waiting patient215          3 TRANSFER_OUT    – move most stable ICU patient to step-down216          4 CALL_EXTRA_NURSE217          5 SPECIALIST_CONSULT218          6 EXPEDITE_BED219        """220        if action not in range(7):221            action = 0222 223        self._cost_this_step = 0.0224        action_result = self._apply_action(action)225 226        # Simulate 30-minute time passage227        self._simulate_time_passage()228 229        # Advance clock230        self._step += 1231        self._hour = (8.0 + self._step * 0.5) % 24232 233        reward = self._calculate_reward(action)234        done = self._step >= self.MAX_STEPS235 236        obs = self._build_obs()237        info = {238            "action_result":       action_result,239            "cost_this_step_inr":  round(self._cost_this_step, 2),240            "deaths_in_queue":     self._deaths_in_queue,241            "adverse_events":      self._adverse_events,242            "admissions_today":    self._admissions_today,243            "transfers_today":     self._transfers_today,244            "wait_violations":     self._wait_violations,245            "nurse_ratio":         round(self._nurse_patient_ratio(), 2),246        }247        return obs, round(reward, 4), done, info248 249    def state(self) -> dict:250        """Return current observation without advancing time."""251        return self._build_obs()252 253    # ─────────────────────────────────────────────────────────────────────254    # Action handlers255    # ─────────────────────────────────────────────────────────────────────256 257    def _apply_action(self, action: int) -> str:258        if action == 0:259            return "HOLD: no allocation change"260 261        elif action == 1:  # ADMIT_CRITICAL262            if not self._queue:263                return "ADMIT_CRITICAL: queue empty"264            bed = self._first_available_bed()265            if bed is None:266                return "ADMIT_CRITICAL: no available bed"267            # Admit highest-SOFA patient268            patient = max(self._queue, key=lambda p: p.sofa)269            self._queue.remove(patient)270            self._admit_patient(bed, patient)271            return f"ADMIT_CRITICAL: admitted P{patient.pid} (SOFA={patient.sofa:.1f}) to Bed {bed.bed_id}"272 273        elif action == 2:  # ADMIT_FIFO274            if not self._queue:275                return "ADMIT_FIFO: queue empty"276            bed = self._first_available_bed()277            if bed is None:278                return "ADMIT_FIFO: no available bed"279            # Admit longest-waiting patient280            patient = min(self._queue, key=lambda p: p.arrival_step)281            self._queue.remove(patient)282            self._admit_patient(bed, patient)283            return f"ADMIT_FIFO: admitted P{patient.pid} (SOFA={patient.sofa:.1f}) to Bed {bed.bed_id}"284 285        elif action == 3:  # TRANSFER_OUT286            candidates = [b for b in self._beds287                          if b.is_occupied and b.patient.sofa <= self.TRANSFER_SOFA_MAX]288            if not candidates:289                return "TRANSFER_OUT: no stable patients eligible"290            # Transfer lowest-SOFA patient291            bed = min(candidates, key=lambda b: b.patient.sofa)292            patient = bed.patient293            bed.patient = None294            bed.turnover_steps_remaining = self._rng.randint(1, 3)  # 30-90 min cleanup295            self._discharged.append(patient)296            self._transfers_today += 1297            # Reclaim equipment298            if patient.needs_ventilator:299                self._ventilators_in_use = max(0, self._ventilators_in_use - 1)300            if patient.needs_dialysis:301                self._dialysis_in_use = max(0, self._dialysis_in_use - 1)302            return f"TRANSFER_OUT: transferred P{patient.pid} (SOFA={patient.sofa:.1f}) to step-down"303 304        elif action == 4:  # CALL_EXTRA_NURSE305            cost = self.OVERTIME_NURSE_COST306            if self._budget_remaining < cost:307                return "CALL_EXTRA_NURSE: insufficient budget"308            self._budget_remaining -= cost309            self._cost_this_step += cost310            self._extra_nurses_called += 1311            return f"CALL_EXTRA_NURSE: +1 nurse this shift (₹{cost})"312 313        elif action == 5:  # SPECIALIST_CONSULT314            # Reduce mortality risk of sickest current patient315            occupied = [b for b in self._beds if b.is_occupied]316            if not occupied:317                return "SPECIALIST_CONSULT: no current patients"318            cost = self.SPECIALIST_COST319            if self._budget_remaining < cost:320                return "SPECIALIST_CONSULT: insufficient budget"321            sickest = max(occupied, key=lambda b: b.patient.mortality_risk)322            old_risk = sickest.patient.mortality_risk323            sickest.patient.mortality_risk = max(0.05, old_risk - 0.15)324            self._budget_remaining -= cost325            self._cost_this_step += cost326            self._specialist_consults += 1327            self._mortality_risks_avoided += (old_risk - sickest.patient.mortality_risk)328            return (f"SPECIALIST_CONSULT: P{sickest.patient.pid} mortality risk "329                    f"{old_risk:.2f}→{sickest.patient.mortality_risk:.2f} (₹{cost})")330 331        elif action == 6:  # EXPEDITE_BED332            turnover_beds = [b for b in self._beds if b.in_turnover]333            if not turnover_beds:334                return "EXPEDITE_BED: no beds in turnover"335            cost = self.EXPEDITE_BED_COST336            if self._budget_remaining < cost:337                return "EXPEDITE_BED: insufficient budget"338            # Reduce turnover time of the bed closest to ready339            target = min(turnover_beds, key=lambda b: b.turnover_steps_remaining)340            target.turnover_steps_remaining = max(0, target.turnover_steps_remaining - 1)341            self._budget_remaining -= cost342            self._cost_this_step += cost343            return f"EXPEDITE_BED: Bed {target.bed_id} ready sooner (₹{cost})"344 345        return "UNKNOWN action"346 347    # ─────────────────────────────────────────────────────────────────────348    # Simulation349    # ─────────────────────────────────────────────────────────────────────350 351    def _simulate_time_passage(self):352        """Advance simulation by 30 minutes."""353        # 1. Existing ICU patients: progress LOS, possibly deteriorate or improve354        for bed in self._beds:355            if not bed.is_occupied:356                continue357            p = bed.patient358            p.los_steps += 1359 360            # Natural deterioration/improvement (small random walk on SOFA)361            delta = self._rng.gauss(0, 0.4)362            p.sofa = max(0.0, min(24.0, p.sofa + delta))363            p.mortality_risk = Patient.sofa_to_mortality(p.sofa)364 365            # Check if patient ready for discharge (completed LOS)366            if p.los_steps >= p.admitted_step + self._rng.randint(4, 16):367                # Patient stable enough for general ward368                if p.sofa <= 8:369                    bed.patient = None370                    bed.turnover_steps_remaining = self._rng.randint(1, 3)371                    self._discharged.append(p)372                    if p.needs_ventilator:373                        self._ventilators_in_use = max(0, self._ventilators_in_use - 1)374                    if p.needs_dialysis:375                        self._dialysis_in_use = max(0, self._dialysis_in_use - 1)376 377            # Adverse event if nurse ratio is unsafe378            if self._nurse_patient_ratio() > self.SAFE_NURSE_RATIO * 1.5:379                if self._rng.random() < 0.08:   # 8% chance per step per patient380                    self._adverse_events += 1381                    p.sofa = min(24.0, p.sofa + 1.5)382                    p.mortality_risk = Patient.sofa_to_mortality(p.sofa)383 384        # 2. Bed turnover countdown385        for bed in self._beds:386            if bed.in_turnover:387                bed.turnover_steps_remaining -= 1388 389        # 3. Waiting queue deterioration and deaths390        for p in list(self._queue):391            wait = self._step - p.arrival_step392            if wait >= self.MAX_QUEUE_WAIT_STEPS and p.sofa >= self.CRITICAL_SOFA:393                self._wait_violations += 1394                # Mortality risk worsens with delay395                p.mortality_risk = min(0.99, p.mortality_risk + self.DELAY_MORTALITY_INCREMENT)396                p.sofa = min(24.0, p.sofa + 0.5)397                # Patient may die in queue398                if p.mortality_risk > 0.90 and self._rng.random() < 0.15:399                    self._queue.remove(p)400                    self._deaths_in_queue += 1401 402        # 4. New arrivals (non-homogeneous Poisson, peaks at 08-12h and 20-24h)403        arrival_rate = self._arrival_rate_per_step()404        n_arrivals = self._rng.poisson_approx(arrival_rate)405        for _ in range(n_arrivals):406            self._queue.append(self._generate_patient())407 408        # 5. Deduct bed operating costs from budget409        occupied_count = sum(1 for b in self._beds if b.is_occupied)410        step_cost = occupied_count * (self.DAILY_BUDGET_INR / self.MAX_STEPS / self.TOTAL_ICU_BEDS)411        self._budget_remaining = max(0.0, self._budget_remaining - step_cost)412 413    def _arrival_rate_per_step(self) -> float:414        """415        Non-homogeneous Poisson arrival rate.416        Peak hours: 08-12 (post-morning rounds referrals) and 20-24 (evening emergencies).417        Based on: Arias-Verdú et al., Critical Care Medicine 2017.418        """419        h = self._hour420        base = 0.4421        if 8 <= h < 12:422            return base * 2.0423        elif 20 <= h < 24:424            return base * 1.8425        elif 14 <= h < 18:426            return base * 1.2427        elif 0 <= h < 6:428            return base * 0.5429        return base430 431    def _generate_patient(self, is_initial: bool = False) -> Patient:432        """Generate a patient with realistic SOFA distribution."""433        self._pid_counter += 1434        # SOFA distribution in Indian ICU referrals (based on published data)435        # ~20% critical (≥11), ~40% severe (7-10), ~40% moderate (0-6)436        r = self._rng.random()437        if r < 0.20:438            sofa = self._rng.uniform(11, 20)    # Critical439        elif r < 0.60:440            sofa = self._rng.uniform(7, 11)     # Severe441        else:442            sofa = self._rng.uniform(1, 7)      # Moderate443 444        # Equipment needs correlate with severity445        needs_vent = sofa >= 9 and self._rng.random() < 0.55446        needs_dial = sofa >= 10 and self._rng.random() < 0.30447 448        expected_los = max(4, int(sofa * 1.5 + self._rng.gauss(0, 2)))449 450        return Patient(451            pid=self._pid_counter,452            sofa=round(sofa, 1),453            needs_ventilator=needs_vent,454            needs_dialysis=needs_dial,455            arrival_step=self._step if not is_initial else -self._rng.randint(2, 8),456            los_steps=0,457            admitted_step=0 if is_initial else None,458        )459 460    def _admit_patient(self, bed: Bed, patient: Patient):461        """Place a patient into a bed and update equipment counts."""462        bed.patient = patient463        patient.admitted_step = self._step464        self._admissions_today += 1465 466        if patient.needs_ventilator and self._ventilators_in_use < self.TOTAL_VENTILATORS:467            self._ventilators_in_use += 1468        elif patient.needs_ventilator:469            patient.needs_ventilator = False   # Can't provide — document as constraint470 471        if patient.needs_dialysis and self._dialysis_in_use < self.TOTAL_DIALYSIS:472            self._dialysis_in_use += 1473        elif patient.needs_dialysis:474            patient.needs_dialysis = False475 476    # ─────────────────────────────────────────────────────────────────────477    # Reward478    # ─────────────────────────────────────────────────────────────────────479 480    def _calculate_reward(self, action: int) -> float:481        """482        Multi-objective reward with partial signals at every step.483 484        Components485        ----------486        +3.0  per critical patient admitted before 2-hour breach487        +1.0  for maintaining safe nurse:patient ratio488        -5.0  per patient death in queue this step489        -2.0  per adverse event this step490        -1.5  per critical patient waiting > 2 hours (ongoing)491        +0.5  per effective specialist consult (action=5 AND patient at risk)492        -0.3  budget overspend fraction (if budget depleted)493        -0.5  for HOLD when critical patient in queue AND bed available (missed opportunity)494        """495        reward = 0.0496 497        # Nurse ratio component498        ratio = self._nurse_patient_ratio()499        if ratio <= self.SAFE_NURSE_RATIO:500            reward += 1.0501        else:502            reward -= (ratio - self.SAFE_NURSE_RATIO) * 1.5503 504        # Critical patients in queue breaching wait time505        for p in self._queue:506            wait = self._step - p.arrival_step507            if p.sofa >= self.CRITICAL_SOFA and wait > self.MAX_QUEUE_WAIT_STEPS:508                reward -= 1.5509 510        # Deaths in queue penalise heavily511        # (deaths_in_queue is cumulative; reward on incremental change is handled512        #  by tracking _last_deaths — approximated here as step-level signal)513        # We track last step deaths via adverse events counter delta514        # Simple: penalise for current step deaths via wait_violations increase515        breach_count = sum(516            1 for p in self._queue517            if p.sofa >= self.CRITICAL_SOFA and (self._step - p.arrival_step) >= self.MAX_QUEUE_WAIT_STEPS518        )519        reward -= breach_count * 0.5520 521        # Missed opportunity: HOLD when could have admitted critical patient522        if action == 0:523            has_critical_queue = any(p.sofa >= self.CRITICAL_SOFA for p in self._queue)524            has_free_bed = self._first_available_bed() is not None525            if has_critical_queue and has_free_bed:526                reward -= 0.5527 528        # Budget management529        budget_fraction = self._budget_remaining / self.DAILY_BUDGET_INR530        if budget_fraction <= 0:531            reward -= 0.3532        else:533            reward += budget_fraction * 0.2534 535        # Throughput bonus: reward for high admissions-to-capacity ratio536        throughput = self._admissions_today / max(1, self._step)537        reward += min(0.5, throughput * 0.5)538 539        # Equipment utilisation (reward efficient use, penalise over-saturation)540        vent_util = self._ventilators_in_use / self.TOTAL_VENTILATORS541        if vent_util > 0.95:542            reward -= 0.4   # Near capacity is dangerous543 544        return reward545 546    # ─────────────────────────────────────────────────────────────────────547    # Observation builder548    # ─────────────────────────────────────────────────────────────────────549 550    def _build_obs(self) -> dict:551        occupied = [b for b in self._beds if b.is_occupied]552        available = [b for b in self._beds if b.is_available]553        turnover  = [b for b in self._beds if b.in_turnover]554 555        q_sofa     = [p.sofa for p in self._queue]556        q_critical = [p for p in self._queue if p.sofa >= self.CRITICAL_SOFA]557        q_severe   = [p for p in self._queue if 7 <= p.sofa < self.CRITICAL_SOFA]558        q_moderate = [p for p in self._queue if p.sofa < 7]559 560        current_sofa = [b.patient.sofa for b in occupied]561        avg_icu_sofa = sum(current_sofa) / max(1, len(current_sofa))562        avg_icu_mortality = sum(b.patient.mortality_risk for b in occupied) / max(1, len(occupied))563 564        longest_wait = 0565        if self._queue:566            longest_wait = self._step - min(p.arrival_step for p in self._queue)567 568        nurses = self._nurses_on_duty()569 570        return {571            # Bed status572            "beds_total":              self.TOTAL_ICU_BEDS,573            "beds_occupied":           len(occupied),574            "beds_available":          len(available),575            "beds_in_turnover":        len(turnover),576 577            # Queue status578            "queue_total":             len(self._queue),579            "queue_critical":          len(q_critical),   # SOFA ≥ 11580            "queue_severe":            len(q_severe),     # SOFA 7-10581            "queue_moderate":          len(q_moderate),   # SOFA < 7582            "queue_max_wait_steps":    longest_wait,       # Steps since oldest arrival583 584            # Current ICU patient acuity585            "avg_icu_sofa":            round(avg_icu_sofa, 2),586            "avg_icu_mortality_risk":  round(avg_icu_mortality, 3),587 588            # Equipment589            "ventilators_available":   self.TOTAL_VENTILATORS - self._ventilators_in_use,590            "ventilators_in_use":      self._ventilators_in_use,591            "dialysis_available":      self.TOTAL_DIALYSIS - self._dialysis_in_use,592 593            # Staff594            "nurses_on_duty":          nurses,595            "nurse_patient_ratio":     round(self._nurse_patient_ratio(), 2),596            "doctors_on_duty":         self.BASE_DOCTORS,597 598            # Time599            "shift":                   self._current_shift(),  # 0=day 1=evening 2=night600            "time_of_day":             round(self._hour, 1),601            "step":                    self._step,602 603            # Finance604            "budget_remaining_inr":    round(self._budget_remaining, 2),605            "budget_utilisation_pct":  round((1 - self._budget_remaining / self.DAILY_BUDGET_INR) * 100, 1),606 607            # Cumulative outcomes608            "admissions_today":        self._admissions_today,609            "transfers_today":         self._transfers_today,610            "deaths_in_queue":         self._deaths_in_queue,611            "adverse_events":          self._adverse_events,612            "wait_violations":         self._wait_violations,613        }614 615    # ─────────────────────────────────────────────────────────────────────616    # Helpers617    # ─────────────────────────────────────────────────────────────────────618 619    def _first_available_bed(self) -> Optional[Bed]:620        for b in self._beds:621            if b.is_available:622                return b623        return None624 625    def _nurses_on_duty(self) -> int:626        base = self.BASE_NURSES_DAY if self._current_shift() == 0 else self.BASE_NURSES_NIGHT627        return base + self._extra_nurses_called628 629    def _nurse_patient_ratio(self) -> float:630        occupied = sum(1 for b in self._beds if b.is_occupied)631        nurses = self._nurses_on_duty()632        if nurses == 0:633            return float("inf")634        return occupied / nurses635 636    def _current_shift(self) -> int:637        h = self._hour638        if 8 <= h < 16:639            return 0   # Day640        elif 16 <= h < 24:641            return 1   # Evening642        else:643            return 2   # Night644 645    class _RNG(random.Random):646        pass647 648 649# Monkey-patch a Poisson approximation onto the rng650def _poisson_approx(self, lam: float) -> int:651    """Approximate Poisson using sum of Bernoulli trials (works for small λ)."""652    n, p = 20, lam / 20653    return sum(1 for _ in range(n) if self.random() < p)654 655random.Random.poisson_approx = _poisson_approx656 657 658# ─────────────────────────────────────────────────────────────────────────────659# Quick smoke test660# ─────────────────────────────────────────────────────────────────────────────661if __name__ == "__main__":662    env = ICUEnv(seed=42)663    obs = env.reset()664    print("── INITIAL STATE ─────────────────────────────────────────")665    for k, v in obs.items():666        print(f"  {k:35s}: {v}")667 668    print("\n── FIRST 6 STEPS ─────────────────────────────────────────")669    total_reward = 0670    for i in range(6):671        action = i % 7672        obs, reward, done, info = env.step(action)673        total_reward += reward674        print(f"Step {i+1} | act={action} | beds={obs['beds_occupied']}/20 "675              f"| queue={obs['queue_total']} (crit={obs['queue_critical']}) "676              f"| reward={reward:+.3f} | budget=₹{obs['budget_remaining_inr']:,.0f} "677              f"| {info['action_result'][:50]}")678 679    print(f"\nTotal reward so far: {total_reward:.3f}")680    print("State OK:", len(env.state()) == 27)681