rohanbelsare commited on
Commit
976e500
Β·
verified Β·
1 Parent(s): c27a3a6

Update openenv_env/healthcare_env.py

Browse files
Files changed (1) hide show
  1. 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
- R = 6371.0
 
 
 
 
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
- self.config = config or {"max_patients": 20, "traffic_mult": 1.0}
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
- obs_size = (4 + self.num_hospitals * 4 + self.num_ambulances * 2)
68
- self.observation_space = spaces.Box(low=0.0, high=1.0, shape=(obs_size,), dtype=np.float32)
69
-
 
 
 
 
 
 
 
 
70
  self.hospitals = []
71
  self.ambulances = []
72
  self.patient = {}
73
  self.step_count = 0
74
  self.episode_rewards = []
75
 
76
- def reset(self, *, seed: Optional[int] = None, options: Optional[dict] = None) -> Tuple[np.ndarray, Dict]:
 
 
 
 
 
 
 
 
 
 
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
- if outcome in ["ambulance_busy", "no_bed"]:
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, float(reward), terminated, truncated, info
147
 
148
  def render(self):
 
149
  if self.render_mode == "human":
150
- print(f"\n[Step {self.step_count:3d}] Patient severity={self.patient['severity']:.1f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) * self.config["traffic_mult"], 2),
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
- if ambulance["status"] != "available": return -50.0, "ambulance_busy"
199
- if hospital["beds_available"] <= 0: return -100.0, "no_bed"
 
 
 
 
 
200
 
 
201
  reward += 100.0
202
 
 
203
  if p["severity"] >= 8:
204
- if hospital["icu_available"] > 0: reward += 70.0
205
- else: reward -= 40.0
206
-
207
- amb_to_patient = haversine_distance(ambulance["lat"], ambulance["lon"], p["lat"], p["lon"])
 
 
 
 
 
208
  eta = compute_eta(amb_to_patient, p["traffic"])
209
- if eta < 10.0: reward += 50.0
210
- elif eta < 20.0: reward += 20.0
 
 
 
 
 
211
 
212
- reward -= min(amb_to_patient * 2.0, 40.0)
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
- info_dict = {
 
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": (np.mean(self.episode_rewards) if self.episode_rewards else 0.0),
 
 
239
  }
240
- if hasattr(self, 'metrics'):
241
- info_dict.update(self.metrics)
242
- return info_dict
 
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
- return hospital_id * self.num_ambulances + ambulance_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"])