Update openenv_env/healthcare_env.py
Browse files- openenv_env/healthcare_env.py +242 -44
openenv_env/healthcare_env.py
CHANGED
|
@@ -3,6 +3,14 @@ healthcare_env.py
|
|
| 3 |
=================
|
| 4 |
AI-Powered Smart Healthcare Routing & Emergency Management
|
| 5 |
OpenEnv / Gymnasium-compatible RL Environment
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import gymnasium as gym
|
|
@@ -12,17 +20,36 @@ from typing import Optional, Dict, Tuple, Any
|
|
| 12 |
import math
|
| 13 |
import random
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
| 18 |
dphi = math.radians(lat2 - lat1)
|
| 19 |
dlambda = math.radians(lon2 - lon1)
|
| 20 |
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
| 21 |
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 22 |
|
|
|
|
| 23 |
def compute_eta(distance_km: float, traffic_factor: float = 1.0, speed_kmh: float = 60.0) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
return (distance_km / speed_kmh) * 60.0 * traffic_factor
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
DEFAULT_HOSPITALS = [
|
| 27 |
{"id": 0, "name": "City General Hospital", "lat": 12.9716, "lon": 77.5946, "total_beds": 100, "icu_beds": 20, "wait_time": 10},
|
| 28 |
{"id": 1, "name": "Apex Medical Centre", "lat": 12.9352, "lon": 77.6244, "total_beds": 80, "icu_beds": 15, "wait_time": 15},
|
|
@@ -38,22 +65,79 @@ DEFAULT_AMBULANCES = [
|
|
| 38 |
{"id": 3, "lat": 13.0000, "lon": 77.5700, "status": "available"},
|
| 39 |
]
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
class HealthcareRoutingEnv(gym.Env):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 4}
|
|
|
|
|
|
|
| 43 |
LAT_MIN, LAT_MAX = 12.85, 13.10
|
| 44 |
LON_MIN, LON_MAX = 77.45, 77.75
|
| 45 |
|
| 46 |
def __init__(
|
| 47 |
self,
|
| 48 |
-
config: Optional[dict] = None,
|
| 49 |
hospitals: Optional[list] = None,
|
| 50 |
ambulances: Optional[list] = None,
|
| 51 |
render_mode: Optional[str] = None,
|
| 52 |
max_steps: int = 200,
|
| 53 |
):
|
| 54 |
super().__init__()
|
| 55 |
-
|
| 56 |
-
|
| 57 |
self.hospitals_template = hospitals or DEFAULT_HOSPITALS
|
| 58 |
self.ambulances_template = ambulances or DEFAULT_AMBULANCES
|
| 59 |
self.render_mode = render_mode
|
|
@@ -62,20 +146,41 @@ class HealthcareRoutingEnv(gym.Env):
|
|
| 62 |
self.num_hospitals = len(self.hospitals_template)
|
| 63 |
self.num_ambulances = len(self.ambulances_template)
|
| 64 |
|
|
|
|
|
|
|
| 65 |
self.action_space = spaces.Discrete(self.num_hospitals * self.num_ambulances)
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
self.hospitals = []
|
| 71 |
self.ambulances = []
|
| 72 |
self.patient = {}
|
| 73 |
self.step_count = 0
|
| 74 |
self.episode_rewards = []
|
| 75 |
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
super().reset(seed=seed)
|
| 78 |
|
|
|
|
| 79 |
self.hospitals = [
|
| 80 |
{
|
| 81 |
**h,
|
|
@@ -86,6 +191,7 @@ class HealthcareRoutingEnv(gym.Env):
|
|
| 86 |
for h in self.hospitals_template
|
| 87 |
]
|
| 88 |
|
|
|
|
| 89 |
self.ambulances = [
|
| 90 |
{
|
| 91 |
**a,
|
|
@@ -96,22 +202,30 @@ class HealthcareRoutingEnv(gym.Env):
|
|
| 96 |
for a in self.ambulances_template
|
| 97 |
]
|
| 98 |
|
|
|
|
| 99 |
self.patient = self._generate_patient()
|
| 100 |
self.step_count = 0
|
| 101 |
-
|
| 102 |
-
self.metrics = {
|
| 103 |
-
"patients_admitted": 0,
|
| 104 |
-
"invalid_actions": 0,
|
| 105 |
-
"critical_saved": 0
|
| 106 |
-
}
|
| 107 |
|
| 108 |
obs = self._get_observation()
|
| 109 |
info = self._get_info()
|
| 110 |
return obs, info
|
| 111 |
|
| 112 |
def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, Dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
self.step_count += 1
|
| 114 |
|
|
|
|
| 115 |
hospital_id = action // self.num_ambulances
|
| 116 |
ambulance_id = action % self.num_ambulances
|
| 117 |
|
|
@@ -120,48 +234,70 @@ class HealthcareRoutingEnv(gym.Env):
|
|
| 120 |
|
| 121 |
reward, outcome = self._compute_reward(hospital, ambulance)
|
| 122 |
|
| 123 |
-
|
| 124 |
-
self.metrics["invalid_actions"] += 1
|
| 125 |
-
elif outcome == "success":
|
| 126 |
-
self.metrics["patients_admitted"] += 1
|
| 127 |
-
if self.patient["severity"] >= 8 and hospital["icu_available"] > 0:
|
| 128 |
-
self.metrics["critical_saved"] += 1
|
| 129 |
-
|
| 130 |
if outcome != "no_bed" and outcome != "ambulance_busy":
|
| 131 |
self._update_state(hospital_id, ambulance_id)
|
| 132 |
|
|
|
|
| 133 |
self.patient = self._generate_patient()
|
| 134 |
|
| 135 |
obs = self._get_observation()
|
| 136 |
-
terminated = False
|
| 137 |
truncated = self.step_count >= self.max_steps
|
| 138 |
info = self._get_info()
|
| 139 |
-
|
| 140 |
info["outcome"] = outcome
|
| 141 |
info["hospital_id"] = hospital_id
|
| 142 |
info["ambulance_id"] = ambulance_id
|
| 143 |
info["reward"] = reward
|
| 144 |
|
| 145 |
self.episode_rewards.append(reward)
|
| 146 |
-
return obs,
|
| 147 |
|
| 148 |
def render(self):
|
|
|
|
| 149 |
if self.render_mode == "human":
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
def close(self):
|
| 153 |
pass
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
def _generate_patient(self) -> Dict:
|
|
|
|
| 156 |
return {
|
| 157 |
"severity": round(random.uniform(1, 10), 1),
|
| 158 |
"lat": random.uniform(self.LAT_MIN, self.LAT_MAX),
|
| 159 |
"lon": random.uniform(self.LON_MIN, self.LON_MAX),
|
| 160 |
-
"traffic": round(random.uniform(0.8, 2.5)
|
| 161 |
}
|
| 162 |
|
| 163 |
def _get_observation(self) -> np.ndarray:
|
|
|
|
| 164 |
p = self.patient
|
|
|
|
|
|
|
| 165 |
sev_norm = (p["severity"] - 1) / 9.0
|
| 166 |
lat_norm = (p["lat"] - self.LAT_MIN) / (self.LAT_MAX - self.LAT_MIN)
|
| 167 |
lon_norm = (p["lon"] - self.LON_MIN) / (self.LON_MAX - self.LON_MIN)
|
|
@@ -169,19 +305,21 @@ class HealthcareRoutingEnv(gym.Env):
|
|
| 169 |
|
| 170 |
obs = [sev_norm, lat_norm, lon_norm, traffic_norm]
|
| 171 |
|
|
|
|
| 172 |
max_beds = max(h["total_beds"] for h in self.hospitals) or 1
|
| 173 |
max_icu = max(h["icu_beds"] for h in self.hospitals) or 1
|
| 174 |
-
max_wait = 120.0
|
| 175 |
|
| 176 |
for h in self.hospitals:
|
| 177 |
dist = haversine_distance(p["lat"], p["lon"], h["lat"], h["lon"])
|
| 178 |
obs += [
|
| 179 |
h["beds_available"] / max_beds,
|
| 180 |
h["icu_available"] / max_icu,
|
| 181 |
-
min(dist / 50.0, 1.0),
|
| 182 |
min(h["current_wait"] / max_wait, 1.0),
|
| 183 |
]
|
| 184 |
|
|
|
|
| 185 |
for a in self.ambulances:
|
| 186 |
dist = haversine_distance(p["lat"], p["lon"], a["lat"], a["lon"])
|
| 187 |
obs += [
|
|
@@ -192,57 +330,117 @@ class HealthcareRoutingEnv(gym.Env):
|
|
| 192 |
return np.array(obs, dtype=np.float32)
|
| 193 |
|
| 194 |
def _compute_reward(self, hospital: Dict, ambulance: Dict) -> Tuple[float, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
p = self.patient
|
| 196 |
reward = 0.0
|
| 197 |
|
| 198 |
-
|
| 199 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
|
|
|
| 201 |
reward += 100.0
|
| 202 |
|
|
|
|
| 203 |
if p["severity"] >= 8:
|
| 204 |
-
if hospital["icu_available"] > 0:
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
eta = compute_eta(amb_to_patient, p["traffic"])
|
| 209 |
-
if eta < 10.0:
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
-
|
| 213 |
-
reward -= min(hospital["current_wait"] * 0.5, 30.0)
|
| 214 |
|
|
|
|
|
|
|
| 215 |
if p["severity"] >= 7 and eta > 15:
|
| 216 |
reward -= (p["severity"] - 7) * 5.0
|
| 217 |
|
| 218 |
return round(reward, 2), "success"
|
| 219 |
|
| 220 |
def _update_state(self, hospital_id: int, ambulance_id: int):
|
|
|
|
| 221 |
h = self.hospitals[hospital_id]
|
| 222 |
a = self.ambulances[ambulance_id]
|
|
|
|
|
|
|
| 223 |
h["beds_available"] = max(0, h["beds_available"] - 1)
|
| 224 |
if self.patient["severity"] >= 8 and h["icu_available"] > 0:
|
| 225 |
h["icu_available"] -= 1
|
|
|
|
|
|
|
| 226 |
h["current_wait"] = min(h["current_wait"] + random.randint(0, 3), 120)
|
|
|
|
|
|
|
| 227 |
a["status"] = "busy"
|
|
|
|
|
|
|
| 228 |
busy = [x for x in self.ambulances if x["status"] == "busy"]
|
| 229 |
if len(busy) == self.num_ambulances and busy:
|
| 230 |
random.choice(busy)["status"] = "available"
|
| 231 |
|
| 232 |
def _get_info(self) -> Dict[str, Any]:
|
| 233 |
-
|
|
|
|
| 234 |
"step": self.step_count,
|
| 235 |
"patient_severity": self.patient.get("severity", 0),
|
| 236 |
"available_beds": sum(h["beds_available"] for h in self.hospitals),
|
| 237 |
"available_ambs": sum(1 for a in self.ambulances if a["status"] == "available"),
|
| 238 |
-
"episode_mean_reward": (
|
|
|
|
|
|
|
| 239 |
}
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
| 243 |
|
| 244 |
def decode_action(self, action: int) -> Tuple[int, int]:
|
|
|
|
| 245 |
return action // self.num_ambulances, action % self.num_ambulances
|
| 246 |
|
| 247 |
def encode_action(self, hospital_id: int, ambulance_id: int) -> int:
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
=================
|
| 4 |
AI-Powered Smart Healthcare Routing & Emergency Management
|
| 5 |
OpenEnv / Gymnasium-compatible RL Environment
|
| 6 |
+
|
| 7 |
+
Compatible with:
|
| 8 |
+
- Meta's OpenEnv spec (https://github.com/meta-pytorch/OpenEnv)
|
| 9 |
+
- HuggingFace openenv-course
|
| 10 |
+
- Standard Gymnasium API
|
| 11 |
+
|
| 12 |
+
Author: Healthcare-RL Team
|
| 13 |
+
Hackathon: Meta PyTorch OpenEnv Hackathon x SST 2026
|
| 14 |
"""
|
| 15 |
|
| 16 |
import gymnasium as gym
|
|
|
|
| 20 |
import math
|
| 21 |
import random
|
| 22 |
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Helper utilities
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 29 |
+
"""
|
| 30 |
+
Calculate the great-circle distance between two GPS points (in km).
|
| 31 |
+
Uses the Haversine formula.
|
| 32 |
+
"""
|
| 33 |
+
R = 6371.0 # Earth radius in kilometres
|
| 34 |
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
| 35 |
dphi = math.radians(lat2 - lat1)
|
| 36 |
dlambda = math.radians(lon2 - lon1)
|
| 37 |
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
| 38 |
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 39 |
|
| 40 |
+
|
| 41 |
def compute_eta(distance_km: float, traffic_factor: float = 1.0, speed_kmh: float = 60.0) -> float:
|
| 42 |
+
"""
|
| 43 |
+
Estimate travel time in minutes.
|
| 44 |
+
traffic_factor > 1 means heavier traffic (slower travel).
|
| 45 |
+
"""
|
| 46 |
return (distance_km / speed_kmh) * 60.0 * traffic_factor
|
| 47 |
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Simulation Data (realistic Indian city coordinates - Bengaluru area)
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
DEFAULT_HOSPITALS = [
|
| 54 |
{"id": 0, "name": "City General Hospital", "lat": 12.9716, "lon": 77.5946, "total_beds": 100, "icu_beds": 20, "wait_time": 10},
|
| 55 |
{"id": 1, "name": "Apex Medical Centre", "lat": 12.9352, "lon": 77.6244, "total_beds": 80, "icu_beds": 15, "wait_time": 15},
|
|
|
|
| 65 |
{"id": 3, "lat": 13.0000, "lon": 77.5700, "status": "available"},
|
| 66 |
]
|
| 67 |
|
| 68 |
+
NUM_HOSPITALS = len(DEFAULT_HOSPITALS)
|
| 69 |
+
NUM_AMBULANCES = len(DEFAULT_AMBULANCES)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
# Core RL Environment
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
|
| 76 |
class HealthcareRoutingEnv(gym.Env):
|
| 77 |
+
"""
|
| 78 |
+
HealthcareRoutingEnv
|
| 79 |
+
--------------------
|
| 80 |
+
A Gymnasium-compatible (OpenEnv-spec) reinforcement learning environment
|
| 81 |
+
for intelligent ambulance dispatch and hospital routing in emergency healthcare.
|
| 82 |
+
|
| 83 |
+
PROBLEM STATEMENT
|
| 84 |
+
-----------------
|
| 85 |
+
When a patient calls for emergency help, the system must decide:
|
| 86 |
+
1. Which ambulance to dispatch (closest + available)?
|
| 87 |
+
2. Which hospital should receive the patient (best match for severity,
|
| 88 |
+
beds available, distance, ICU availability)?
|
| 89 |
+
|
| 90 |
+
The agent learns to maximise patient outcomes while minimising response
|
| 91 |
+
times and avoiding poor resource allocation.
|
| 92 |
+
|
| 93 |
+
STATE SPACE (observation)
|
| 94 |
+
-------------------------
|
| 95 |
+
A flat numpy array containing:
|
| 96 |
+
- patient_severity : float [0, 1] (normalised 1β10)
|
| 97 |
+
- patient_lat : float [0, 1] (normalised)
|
| 98 |
+
- patient_lon : float [0, 1] (normalised)
|
| 99 |
+
- traffic_condition : float [0, 1] (1 = worst traffic)
|
| 100 |
+
- For each hospital (NUM_HOSPITALS):
|
| 101 |
+
- beds_available_norm : float [0, 1]
|
| 102 |
+
- icu_beds_norm : float [0, 1]
|
| 103 |
+
- distance_norm : float [0, 1]
|
| 104 |
+
- wait_time_norm : float [0, 1]
|
| 105 |
+
- For each ambulance (NUM_AMBULANCES):
|
| 106 |
+
- distance_to_patient : float [0, 1]
|
| 107 |
+
- is_available : float {0, 1}
|
| 108 |
+
|
| 109 |
+
ACTION SPACE
|
| 110 |
+
------------
|
| 111 |
+
Discrete: NUM_HOSPITALS Γ NUM_AMBULANCES
|
| 112 |
+
(i.e. choose one (hospital, ambulance) pair from all combinations)
|
| 113 |
+
|
| 114 |
+
REWARD FUNCTION
|
| 115 |
+
---------------
|
| 116 |
+
+100 patient successfully admitted
|
| 117 |
+
+70 critical patient (severity >= 8) gets ICU bed
|
| 118 |
+
+50 ambulance arrives fast (ETA < 10 min)
|
| 119 |
+
-100 no bed available at chosen hospital
|
| 120 |
+
-50 ambulance already busy / unavailable
|
| 121 |
+
-distance_penalty proportional to ambulance travel distance
|
| 122 |
+
-wait_penalty proportional to hospital wait time
|
| 123 |
+
-severity_penalty if critical patient sent to hospital with no ICU
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 4}
|
| 127 |
+
|
| 128 |
+
# Geographic bounding box for normalisation (Bengaluru region)
|
| 129 |
LAT_MIN, LAT_MAX = 12.85, 13.10
|
| 130 |
LON_MIN, LON_MAX = 77.45, 77.75
|
| 131 |
|
| 132 |
def __init__(
|
| 133 |
self,
|
|
|
|
| 134 |
hospitals: Optional[list] = None,
|
| 135 |
ambulances: Optional[list] = None,
|
| 136 |
render_mode: Optional[str] = None,
|
| 137 |
max_steps: int = 200,
|
| 138 |
):
|
| 139 |
super().__init__()
|
| 140 |
+
|
|
|
|
| 141 |
self.hospitals_template = hospitals or DEFAULT_HOSPITALS
|
| 142 |
self.ambulances_template = ambulances or DEFAULT_AMBULANCES
|
| 143 |
self.render_mode = render_mode
|
|
|
|
| 146 |
self.num_hospitals = len(self.hospitals_template)
|
| 147 |
self.num_ambulances = len(self.ambulances_template)
|
| 148 |
|
| 149 |
+
# ββ Action space ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
# Flat index: action = hospital_id * num_ambulances + ambulance_id
|
| 151 |
self.action_space = spaces.Discrete(self.num_hospitals * self.num_ambulances)
|
| 152 |
|
| 153 |
+
# ββ Observation space βββββββββββββββββββββββββββββββββββββββββββββ
|
| 154 |
+
obs_size = (
|
| 155 |
+
4 # patient severity, lat, lon, traffic
|
| 156 |
+
+ self.num_hospitals * 4 # beds, icu, distance, wait per hospital
|
| 157 |
+
+ self.num_ambulances * 2 # distance, availability per ambulance
|
| 158 |
+
)
|
| 159 |
+
self.observation_space = spaces.Box(
|
| 160 |
+
low=0.0, high=1.0, shape=(obs_size,), dtype=np.float32
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Internal state (populated on reset)
|
| 164 |
self.hospitals = []
|
| 165 |
self.ambulances = []
|
| 166 |
self.patient = {}
|
| 167 |
self.step_count = 0
|
| 168 |
self.episode_rewards = []
|
| 169 |
|
| 170 |
+
# ------------------------------------------------------------------
|
| 171 |
+
# OpenEnv / Gymnasium API
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
def reset(
|
| 175 |
+
self,
|
| 176 |
+
*,
|
| 177 |
+
seed: Optional[int] = None,
|
| 178 |
+
options: Optional[dict] = None,
|
| 179 |
+
) -> Tuple[np.ndarray, Dict]:
|
| 180 |
+
"""Reset the environment and return the initial observation."""
|
| 181 |
super().reset(seed=seed)
|
| 182 |
|
| 183 |
+
# Deep-copy hospital data so beds change per episode
|
| 184 |
self.hospitals = [
|
| 185 |
{
|
| 186 |
**h,
|
|
|
|
| 191 |
for h in self.hospitals_template
|
| 192 |
]
|
| 193 |
|
| 194 |
+
# Reset ambulances (all available, slight position jitter)
|
| 195 |
self.ambulances = [
|
| 196 |
{
|
| 197 |
**a,
|
|
|
|
| 202 |
for a in self.ambulances_template
|
| 203 |
]
|
| 204 |
|
| 205 |
+
# Generate a new emergency patient
|
| 206 |
self.patient = self._generate_patient()
|
| 207 |
self.step_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
obs = self._get_observation()
|
| 210 |
info = self._get_info()
|
| 211 |
return obs, info
|
| 212 |
|
| 213 |
def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, Dict]:
|
| 214 |
+
"""
|
| 215 |
+
Execute one decision step.
|
| 216 |
+
|
| 217 |
+
Parameters
|
| 218 |
+
----------
|
| 219 |
+
action : int
|
| 220 |
+
Flat index encoding (hospital_id, ambulance_id)
|
| 221 |
+
|
| 222 |
+
Returns
|
| 223 |
+
-------
|
| 224 |
+
observation, reward, terminated, truncated, info
|
| 225 |
+
"""
|
| 226 |
self.step_count += 1
|
| 227 |
|
| 228 |
+
# Decode action
|
| 229 |
hospital_id = action // self.num_ambulances
|
| 230 |
ambulance_id = action % self.num_ambulances
|
| 231 |
|
|
|
|
| 234 |
|
| 235 |
reward, outcome = self._compute_reward(hospital, ambulance)
|
| 236 |
|
| 237 |
+
# Update world state after the assignment
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
if outcome != "no_bed" and outcome != "ambulance_busy":
|
| 239 |
self._update_state(hospital_id, ambulance_id)
|
| 240 |
|
| 241 |
+
# Generate the next patient for the next step
|
| 242 |
self.patient = self._generate_patient()
|
| 243 |
|
| 244 |
obs = self._get_observation()
|
| 245 |
+
terminated = False # episode runs for max_steps
|
| 246 |
truncated = self.step_count >= self.max_steps
|
| 247 |
info = self._get_info()
|
|
|
|
| 248 |
info["outcome"] = outcome
|
| 249 |
info["hospital_id"] = hospital_id
|
| 250 |
info["ambulance_id"] = ambulance_id
|
| 251 |
info["reward"] = reward
|
| 252 |
|
| 253 |
self.episode_rewards.append(reward)
|
| 254 |
+
return obs, reward, terminated, truncated, info
|
| 255 |
|
| 256 |
def render(self):
|
| 257 |
+
"""Simple human-readable console render."""
|
| 258 |
if self.render_mode == "human":
|
| 259 |
+
p = self.patient
|
| 260 |
+
print(
|
| 261 |
+
f"\n[Step {self.step_count:3d}] "
|
| 262 |
+
f"Patient severity={p['severity']:.1f} "
|
| 263 |
+
f"@ ({p['lat']:.4f}, {p['lon']:.4f}) | "
|
| 264 |
+
f"Traffic={p['traffic']:.2f}"
|
| 265 |
+
)
|
| 266 |
+
for h in self.hospitals:
|
| 267 |
+
print(
|
| 268 |
+
f" π₯ {h['name']:30s} "
|
| 269 |
+
f"beds={h['beds_available']:3d}/{h['total_beds']:3d} "
|
| 270 |
+
f"ICU={h['icu_available']:2d}/{h['icu_beds']:2d} "
|
| 271 |
+
f"wait={h['current_wait']:2d}min"
|
| 272 |
+
)
|
| 273 |
+
for a in self.ambulances:
|
| 274 |
+
print(
|
| 275 |
+
f" π Ambulance-{a['id']} "
|
| 276 |
+
f"status={a['status']:10s} "
|
| 277 |
+
f"@ ({a['lat']:.4f}, {a['lon']:.4f})"
|
| 278 |
+
)
|
| 279 |
|
| 280 |
def close(self):
|
| 281 |
pass
|
| 282 |
|
| 283 |
+
# ------------------------------------------------------------------
|
| 284 |
+
# Internal helpers
|
| 285 |
+
# ------------------------------------------------------------------
|
| 286 |
+
|
| 287 |
def _generate_patient(self) -> Dict:
|
| 288 |
+
"""Randomly generate a new emergency patient within the bounding box."""
|
| 289 |
return {
|
| 290 |
"severity": round(random.uniform(1, 10), 1),
|
| 291 |
"lat": random.uniform(self.LAT_MIN, self.LAT_MAX),
|
| 292 |
"lon": random.uniform(self.LON_MIN, self.LON_MAX),
|
| 293 |
+
"traffic": round(random.uniform(0.8, 2.5), 2), # traffic factor
|
| 294 |
}
|
| 295 |
|
| 296 |
def _get_observation(self) -> np.ndarray:
|
| 297 |
+
"""Build the flat observation vector."""
|
| 298 |
p = self.patient
|
| 299 |
+
|
| 300 |
+
# Normalise patient fields
|
| 301 |
sev_norm = (p["severity"] - 1) / 9.0
|
| 302 |
lat_norm = (p["lat"] - self.LAT_MIN) / (self.LAT_MAX - self.LAT_MIN)
|
| 303 |
lon_norm = (p["lon"] - self.LON_MIN) / (self.LON_MAX - self.LON_MIN)
|
|
|
|
| 305 |
|
| 306 |
obs = [sev_norm, lat_norm, lon_norm, traffic_norm]
|
| 307 |
|
| 308 |
+
# Hospital features
|
| 309 |
max_beds = max(h["total_beds"] for h in self.hospitals) or 1
|
| 310 |
max_icu = max(h["icu_beds"] for h in self.hospitals) or 1
|
| 311 |
+
max_wait = 120.0 # normalise wait time up to 120 min
|
| 312 |
|
| 313 |
for h in self.hospitals:
|
| 314 |
dist = haversine_distance(p["lat"], p["lon"], h["lat"], h["lon"])
|
| 315 |
obs += [
|
| 316 |
h["beds_available"] / max_beds,
|
| 317 |
h["icu_available"] / max_icu,
|
| 318 |
+
min(dist / 50.0, 1.0), # normalise distance up to 50 km
|
| 319 |
min(h["current_wait"] / max_wait, 1.0),
|
| 320 |
]
|
| 321 |
|
| 322 |
+
# Ambulance features
|
| 323 |
for a in self.ambulances:
|
| 324 |
dist = haversine_distance(p["lat"], p["lon"], a["lat"], a["lon"])
|
| 325 |
obs += [
|
|
|
|
| 330 |
return np.array(obs, dtype=np.float32)
|
| 331 |
|
| 332 |
def _compute_reward(self, hospital: Dict, ambulance: Dict) -> Tuple[float, str]:
|
| 333 |
+
"""
|
| 334 |
+
Calculate the reward for assigning a patient to this
|
| 335 |
+
(hospital, ambulance) pair.
|
| 336 |
+
"""
|
| 337 |
p = self.patient
|
| 338 |
reward = 0.0
|
| 339 |
|
| 340 |
+
# ββ Penalty: ambulance not available βββββββββββββββββββββββββββββ
|
| 341 |
+
if ambulance["status"] != "available":
|
| 342 |
+
return -50.0, "ambulance_busy"
|
| 343 |
+
|
| 344 |
+
# ββ Penalty: no bed available βββββββββββββββββββββββββββββββββββββ
|
| 345 |
+
if hospital["beds_available"] <= 0:
|
| 346 |
+
return -100.0, "no_bed"
|
| 347 |
|
| 348 |
+
# ββ Base reward: patient admitted βββββββββββββββββββββββββββββββββ
|
| 349 |
reward += 100.0
|
| 350 |
|
| 351 |
+
# ββ Bonus: critical patient gets ICU βββββββββββββββββββββββββββββ
|
| 352 |
if p["severity"] >= 8:
|
| 353 |
+
if hospital["icu_available"] > 0:
|
| 354 |
+
reward += 70.0
|
| 355 |
+
else:
|
| 356 |
+
reward -= 40.0 # severity_penalty: no ICU for critical
|
| 357 |
+
|
| 358 |
+
# ββ Bonus: fast ambulance arrival βββββββββββββββββββββββββββββββββ
|
| 359 |
+
amb_to_patient = haversine_distance(
|
| 360 |
+
ambulance["lat"], ambulance["lon"], p["lat"], p["lon"]
|
| 361 |
+
)
|
| 362 |
eta = compute_eta(amb_to_patient, p["traffic"])
|
| 363 |
+
if eta < 10.0:
|
| 364 |
+
reward += 50.0
|
| 365 |
+
elif eta < 20.0:
|
| 366 |
+
reward += 20.0
|
| 367 |
+
|
| 368 |
+
# ββ Distance penalty (ambulance to patient) βββββββββββββββββββββββ
|
| 369 |
+
reward -= min(amb_to_patient * 2.0, 40.0) # up to -40
|
| 370 |
|
| 371 |
+
# ββ Wait time penalty βββββββββββββββββββββββββββββββββββββββββββββ
|
| 372 |
+
reward -= min(hospital["current_wait"] * 0.5, 30.0) # up to -30
|
| 373 |
|
| 374 |
+
# ββ Severity Γ delay penalty ββββββββββββββββββββββββββββββββββββββ
|
| 375 |
+
# If patient is critical but ETA is high β extra penalty
|
| 376 |
if p["severity"] >= 7 and eta > 15:
|
| 377 |
reward -= (p["severity"] - 7) * 5.0
|
| 378 |
|
| 379 |
return round(reward, 2), "success"
|
| 380 |
|
| 381 |
def _update_state(self, hospital_id: int, ambulance_id: int):
|
| 382 |
+
"""Consume resources after a successful assignment."""
|
| 383 |
h = self.hospitals[hospital_id]
|
| 384 |
a = self.ambulances[ambulance_id]
|
| 385 |
+
|
| 386 |
+
# Decrement beds
|
| 387 |
h["beds_available"] = max(0, h["beds_available"] - 1)
|
| 388 |
if self.patient["severity"] >= 8 and h["icu_available"] > 0:
|
| 389 |
h["icu_available"] -= 1
|
| 390 |
+
|
| 391 |
+
# Slightly increase wait time due to load
|
| 392 |
h["current_wait"] = min(h["current_wait"] + random.randint(0, 3), 120)
|
| 393 |
+
|
| 394 |
+
# Mark ambulance busy (would return to available after a trip in full sim)
|
| 395 |
a["status"] = "busy"
|
| 396 |
+
|
| 397 |
+
# Free one random busy ambulance so the episode doesn't dead-lock
|
| 398 |
busy = [x for x in self.ambulances if x["status"] == "busy"]
|
| 399 |
if len(busy) == self.num_ambulances and busy:
|
| 400 |
random.choice(busy)["status"] = "available"
|
| 401 |
|
| 402 |
def _get_info(self) -> Dict[str, Any]:
|
| 403 |
+
"""Return auxiliary info for logging / debugging."""
|
| 404 |
+
return {
|
| 405 |
"step": self.step_count,
|
| 406 |
"patient_severity": self.patient.get("severity", 0),
|
| 407 |
"available_beds": sum(h["beds_available"] for h in self.hospitals),
|
| 408 |
"available_ambs": sum(1 for a in self.ambulances if a["status"] == "available"),
|
| 409 |
+
"episode_mean_reward": (
|
| 410 |
+
np.mean(self.episode_rewards) if self.episode_rewards else 0.0
|
| 411 |
+
),
|
| 412 |
}
|
| 413 |
+
|
| 414 |
+
# ------------------------------------------------------------------
|
| 415 |
+
# Convenience: decode action β (hospital, ambulance)
|
| 416 |
+
# ------------------------------------------------------------------
|
| 417 |
|
| 418 |
def decode_action(self, action: int) -> Tuple[int, int]:
|
| 419 |
+
"""Return (hospital_id, ambulance_id) from flat action index."""
|
| 420 |
return action // self.num_ambulances, action % self.num_ambulances
|
| 421 |
|
| 422 |
def encode_action(self, hospital_id: int, ambulance_id: int) -> int:
|
| 423 |
+
"""Return flat action index from (hospital_id, ambulance_id)."""
|
| 424 |
+
return hospital_id * self.num_ambulances + ambulance_id
|
| 425 |
+
|
| 426 |
+
def get_greedy_action(self) -> int:
|
| 427 |
+
"""
|
| 428 |
+
Rule-based greedy baseline (useful for comparison with RL).
|
| 429 |
+
Picks nearest available ambulance + hospital with most beds.
|
| 430 |
+
"""
|
| 431 |
+
p = self.patient
|
| 432 |
+
# Best ambulance: nearest available
|
| 433 |
+
best_amb = min(
|
| 434 |
+
(a for a in self.ambulances if a["status"] == "available"),
|
| 435 |
+
key=lambda a: haversine_distance(p["lat"], p["lon"], a["lat"], a["lon"]),
|
| 436 |
+
default=self.ambulances[0],
|
| 437 |
+
)
|
| 438 |
+
# Best hospital: max beds, weighted by distance
|
| 439 |
+
best_hosp = max(
|
| 440 |
+
self.hospitals,
|
| 441 |
+
key=lambda h: (
|
| 442 |
+
h["beds_available"] * 10
|
| 443 |
+
- haversine_distance(p["lat"], p["lon"], h["lat"], h["lon"])
|
| 444 |
+
),
|
| 445 |
+
)
|
| 446 |
+
return self.encode_action(best_hosp["id"], best_amb["id"])
|