Arun-Sanjay commited on
Commit
07473e9
·
1 Parent(s): 50e3063

Add DispatchSimulation engine, geometry helpers, caller text templates, and observation renderer

Browse files
Files changed (4) hide show
  1. scenario_loader.py +36 -0
  2. simulation.py +405 -0
  3. text_view.py +142 -0
  4. utils.py +152 -0
scenario_loader.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load task scenarios from YAML files bundled with the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import List
7
+
8
+ import yaml
9
+
10
+ # Resolve the tasks directory relative to this file so it works regardless of CWD
11
+ _TASKS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks")
12
+
13
+ VALID_TASKS = ("easy", "medium", "hard")
14
+
15
+
16
+ def load_scenario(task_name: str) -> dict:
17
+ """Load and return the scenario dict for the given task.
18
+
19
+ Raises:
20
+ ValueError: if task_name is not one of {easy, medium, hard}.
21
+ FileNotFoundError: if the YAML file is missing.
22
+ """
23
+ if task_name not in VALID_TASKS:
24
+ raise ValueError(
25
+ f"Unknown task '{task_name}'. Valid tasks: {', '.join(VALID_TASKS)}"
26
+ )
27
+ path = os.path.join(_TASKS_DIR, f"{task_name}.yaml")
28
+ with open(path, "r", encoding="utf-8") as f:
29
+ scenario = yaml.safe_load(f)
30
+ if "name" not in scenario:
31
+ scenario["name"] = task_name
32
+ return scenario
33
+
34
+
35
+ def list_tasks() -> List[str]:
36
+ return list(VALID_TASKS)
simulation.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DispatchSimulation engine. Pure Python, deterministic, seedable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List, Optional, Tuple
6
+
7
+ import numpy as np
8
+
9
+ from models import (
10
+ EmergencyCall,
11
+ EmergencyType,
12
+ EmergencyUnit,
13
+ Hospital,
14
+ Position,
15
+ Severity,
16
+ UnitStatus,
17
+ UnitType,
18
+ WorldConfig,
19
+ )
20
+ from reward import calculate_call_outcome, get_effectiveness
21
+ from utils import (
22
+ calculate_distance,
23
+ calculate_eta,
24
+ generate_caller_text,
25
+ get_capable_units,
26
+ get_optimal_unit,
27
+ )
28
+
29
+
30
+ def _parse_severity(value) -> Severity:
31
+ if isinstance(value, Severity):
32
+ return value
33
+ return Severity(int(value))
34
+
35
+
36
+ def generate_call_schedule(scenario: dict, seed: int) -> List[EmergencyCall]:
37
+ """Build a deterministic list of EmergencyCall objects from a scenario dict."""
38
+ rng = np.random.RandomState(seed)
39
+ calls: List[EmergencyCall] = []
40
+ grid_size = scenario.get("grid_size", 10.0)
41
+ inaccuracy = float(scenario.get("caller_inaccuracy", 0.0))
42
+
43
+ for idx, call_cfg in enumerate(scenario["calls"], start=1):
44
+ true_type = EmergencyType(call_cfg["type"])
45
+ true_severity = _parse_severity(call_cfg["severity"])
46
+
47
+ if inaccuracy > 0 and rng.random() < inaccuracy:
48
+ other_types = [t for t in EmergencyType if t != true_type]
49
+ reported_type = EmergencyType(str(rng.choice([t.value for t in other_types])))
50
+ shifted = max(1, min(5, true_severity.value + int(rng.randint(-1, 2))))
51
+ reported_severity = Severity(shifted)
52
+ else:
53
+ reported_type = true_type
54
+ reported_severity = true_severity
55
+
56
+ location = Position(
57
+ x=round(float(rng.uniform(0.5, grid_size - 0.5)), 1),
58
+ y=round(float(rng.uniform(0.5, grid_size - 0.5)), 1),
59
+ )
60
+ caller_text = generate_caller_text(true_type, reported_type, rng)
61
+
62
+ calls.append(
63
+ EmergencyCall(
64
+ call_id=f"CALL-{idx:03d}",
65
+ timestamp=int(call_cfg["arrival_minute"]),
66
+ caller_description=caller_text,
67
+ location=location,
68
+ true_type=true_type,
69
+ true_severity=true_severity,
70
+ reported_type=reported_type,
71
+ reported_severity=reported_severity,
72
+ requires_unit_types=get_capable_units(true_type),
73
+ optimal_unit_type=get_optimal_unit(true_type),
74
+ )
75
+ )
76
+
77
+ calls.sort(key=lambda c: c.timestamp)
78
+ return calls
79
+
80
+
81
+ # Scene-time table: how long a unit stays on scene treating a call
82
+ SCENE_TIME_MINUTES = {
83
+ EmergencyType.CARDIAC_ARREST: 20,
84
+ EmergencyType.TRAUMA: 25,
85
+ EmergencyType.STROKE: 15,
86
+ EmergencyType.FIRE: 30,
87
+ EmergencyType.BREATHING: 15,
88
+ EmergencyType.MINOR_INJURY: 10,
89
+ EmergencyType.MENTAL_HEALTH: 20,
90
+ }
91
+
92
+
93
+ class DispatchSimulation:
94
+ """Discrete-time simulation of an emergency dispatch episode."""
95
+
96
+ def __init__(self, scenario: dict, seed: int = 42) -> None:
97
+ self.scenario_name: str = scenario.get("name", "unnamed")
98
+ self.scenario: dict = scenario
99
+ self.seed: int = seed
100
+ self.rng = np.random.RandomState(seed)
101
+
102
+ world_cfg = scenario.get("world_config", {})
103
+ self.config = WorldConfig(**world_cfg)
104
+
105
+ self.current_time: int = 0
106
+ self.episode_done: bool = False
107
+
108
+ self.all_calls: List[EmergencyCall] = generate_call_schedule(scenario, seed)
109
+ self.active_calls: List[EmergencyCall] = []
110
+ self.completed_calls: List[dict] = []
111
+ self.timed_out_calls: List[dict] = []
112
+ self.dispatches: List[dict] = []
113
+
114
+ self.units: Dict[str, EmergencyUnit] = {}
115
+ for unit_cfg in scenario["units"]:
116
+ unit = EmergencyUnit(**unit_cfg)
117
+ self.units[unit.unit_id] = unit
118
+
119
+ self.hospitals: Dict[str, Hospital] = {}
120
+ for hosp_cfg in scenario["hospitals"]:
121
+ hosp = Hospital(**hosp_cfg)
122
+ self.hospitals[hosp.hospital_id] = hosp
123
+
124
+ self.call_index: int = 0
125
+ # Release any calls scheduled for time 0
126
+ self._release_due_calls()
127
+
128
+ # ------------------------------------------------------------------
129
+ # Time advancement
130
+ # ------------------------------------------------------------------
131
+
132
+ def _release_due_calls(self) -> None:
133
+ """Move calls whose arrival time has passed into the active queue."""
134
+ while (
135
+ self.call_index < len(self.all_calls)
136
+ and self.all_calls[self.call_index].timestamp <= self.current_time
137
+ ):
138
+ call = self.all_calls[self.call_index]
139
+ call.active = True
140
+ self.active_calls.append(call)
141
+ self.call_index += 1
142
+
143
+ def advance_time(self, minutes: int = 1) -> None:
144
+ """Step the simulation forward by ``minutes`` discrete minutes."""
145
+ if self.episode_done:
146
+ return
147
+ minutes = max(1, int(minutes))
148
+ for _ in range(minutes):
149
+ self.current_time += 1
150
+ self._tick_once()
151
+ if self.episode_done:
152
+ break
153
+
154
+ def _tick_once(self) -> None:
155
+ """Advance simulation by exactly one minute, updating units & calls."""
156
+ # 1. Move units according to their status
157
+ for unit in self.units.values():
158
+ if unit.status == UnitStatus.EN_ROUTE:
159
+ self._move_unit_toward_call(unit)
160
+ elif unit.status == UnitStatus.ON_SCENE:
161
+ if unit.busy_until is not None and self.current_time >= unit.busy_until:
162
+ unit.status = UnitStatus.RETURNING
163
+ unit.assigned_call_id = None
164
+ unit.assigned_hospital_id = None
165
+ elif unit.status == UnitStatus.RETURNING:
166
+ self._move_unit_toward_base(unit)
167
+
168
+ # 2. Time-out any active call that has waited too long
169
+ for call in list(self.active_calls):
170
+ if call.dispatched_unit_id is None:
171
+ wait = self.current_time - call.timestamp
172
+ if wait >= self.config.call_timeout_minutes:
173
+ call.active = False
174
+ self.active_calls.remove(call)
175
+ self.timed_out_calls.append(
176
+ {
177
+ "call_id": call.call_id,
178
+ "true_type": call.true_type.value,
179
+ "true_severity": call.true_severity.value,
180
+ "outcome_score": 0.0,
181
+ "reason": "timed_out",
182
+ }
183
+ )
184
+
185
+ # 3. Release new calls
186
+ self._release_due_calls()
187
+
188
+ # 4. Episode end conditions
189
+ if self.current_time >= self.config.time_limit_minutes:
190
+ self._finalize_episode("time_limit")
191
+ return
192
+
193
+ no_more_incoming = self.call_index >= len(self.all_calls)
194
+ no_pending = all(c.dispatched_unit_id is not None for c in self.active_calls)
195
+ all_units_idle = all(u.status == UnitStatus.AVAILABLE for u in self.units.values())
196
+ if no_more_incoming and no_pending and all_units_idle and not self.active_calls:
197
+ self._finalize_episode("all_resolved")
198
+
199
+ def _finalize_episode(self, reason: str) -> None:
200
+ """Mark episode done.
201
+
202
+ Any remaining call (whether un-dispatched OR dispatched but the unit
203
+ never actually arrived on scene) is recorded as a timeout — the agent
204
+ failed to deliver care in time, so the patient outcome is 0.0.
205
+ """
206
+ self.episode_done = True
207
+ for call in list(self.active_calls):
208
+ self.timed_out_calls.append(
209
+ {
210
+ "call_id": call.call_id,
211
+ "true_type": call.true_type.value,
212
+ "true_severity": call.true_severity.value,
213
+ "outcome_score": 0.0,
214
+ "reason": reason
215
+ if call.dispatched_unit_id is None
216
+ else f"{reason}_in_transit",
217
+ }
218
+ )
219
+ self.active_calls.clear()
220
+
221
+ # ------------------------------------------------------------------
222
+ # Unit movement
223
+ # ------------------------------------------------------------------
224
+
225
+ def _move_unit_toward_call(self, unit: EmergencyUnit) -> None:
226
+ call = self._get_call_by_id(unit.assigned_call_id) if unit.assigned_call_id else None
227
+ if call is None:
228
+ unit.status = UnitStatus.AVAILABLE
229
+ unit.assigned_call_id = None
230
+ return
231
+
232
+ distance_per_step = (unit.speed_kmh / 60.0) * self.config.step_duration_minutes
233
+ dist = calculate_distance(unit.position, call.location)
234
+
235
+ if dist <= distance_per_step:
236
+ unit.position = Position(x=call.location.x, y=call.location.y)
237
+ unit.status = UnitStatus.ON_SCENE
238
+ response_time = float(self.current_time - call.timestamp)
239
+ call.response_time = response_time
240
+
241
+ hospital = (
242
+ self.hospitals.get(unit.assigned_hospital_id)
243
+ if unit.assigned_hospital_id
244
+ else None
245
+ )
246
+
247
+ outcome = calculate_call_outcome(call, unit, response_time, hospital)
248
+ call.outcome_score = outcome
249
+ call.active = False
250
+ if call in self.active_calls:
251
+ self.active_calls.remove(call)
252
+
253
+ if hospital is not None and not hospital.on_diversion and hospital.available_beds > 0:
254
+ hospital.available_beds = max(0, hospital.available_beds - 1)
255
+ call.delivered_hospital_id = hospital.hospital_id
256
+
257
+ self.completed_calls.append(
258
+ {
259
+ "call_id": call.call_id,
260
+ "true_type": call.true_type.value,
261
+ "true_severity": call.true_severity.value,
262
+ "response_time": response_time,
263
+ "outcome_score": outcome,
264
+ "unit_id": unit.unit_id,
265
+ "unit_type": unit.unit_type.value,
266
+ "effectiveness": get_effectiveness(unit.unit_type, call.true_type),
267
+ "hospital_id": call.delivered_hospital_id,
268
+ }
269
+ )
270
+
271
+ scene_time = SCENE_TIME_MINUTES.get(call.true_type, 15)
272
+ unit.busy_until = self.current_time + scene_time
273
+ else:
274
+ ratio = distance_per_step / dist
275
+ unit.position = Position(
276
+ x=round(unit.position.x + (call.location.x - unit.position.x) * ratio, 3),
277
+ y=round(unit.position.y + (call.location.y - unit.position.y) * ratio, 3),
278
+ )
279
+
280
+ def _move_unit_toward_base(self, unit: EmergencyUnit) -> None:
281
+ distance_per_step = (unit.speed_kmh / 60.0) * self.config.step_duration_minutes
282
+ dist = calculate_distance(unit.position, unit.base_position)
283
+ if dist <= distance_per_step:
284
+ unit.position = Position(x=unit.base_position.x, y=unit.base_position.y)
285
+ unit.status = UnitStatus.AVAILABLE
286
+ unit.busy_until = None
287
+ else:
288
+ ratio = distance_per_step / dist
289
+ unit.position = Position(
290
+ x=round(unit.position.x + (unit.base_position.x - unit.position.x) * ratio, 3),
291
+ y=round(unit.position.y + (unit.base_position.y - unit.position.y) * ratio, 3),
292
+ )
293
+
294
+ # ------------------------------------------------------------------
295
+ # Action handlers (called from the MCP environment)
296
+ # ------------------------------------------------------------------
297
+
298
+ def dispatch(
299
+ self, call_id: str, unit_id: str, hospital_id: Optional[str] = None
300
+ ) -> Tuple[float, str]:
301
+ """Dispatch a unit to a call (optionally pre-assigning a destination hospital)."""
302
+ call = self._get_active_undispatched_call(call_id)
303
+ if call is None:
304
+ return -0.05, f"Call {call_id} not found in pending queue."
305
+
306
+ unit = self.units.get(unit_id)
307
+ if unit is None:
308
+ return -0.05, f"Unit {unit_id} not found."
309
+ if unit.status != UnitStatus.AVAILABLE:
310
+ return -0.05, f"Unit {unit_id} is {unit.status.value}, cannot dispatch."
311
+
312
+ # Treat empty string / whitespace as "no hospital chosen"
313
+ if isinstance(hospital_id, str):
314
+ hospital_id = hospital_id.strip() or None
315
+
316
+ chosen_hospital = None
317
+ if hospital_id is not None:
318
+ chosen_hospital = self.hospitals.get(hospital_id)
319
+ if chosen_hospital is None:
320
+ return -0.02, f"Hospital '{hospital_id}' not found."
321
+
322
+ unit.status = UnitStatus.EN_ROUTE
323
+ unit.assigned_call_id = call.call_id
324
+ unit.assigned_hospital_id = hospital_id
325
+ call.dispatched_unit_id = unit.unit_id
326
+
327
+ eta = calculate_eta(unit, call.location)
328
+ effectiveness = get_effectiveness(unit.unit_type, call.true_type)
329
+
330
+ self.dispatches.append(
331
+ {
332
+ "call_id": call.call_id,
333
+ "unit_id": unit.unit_id,
334
+ "unit_type": unit.unit_type.value,
335
+ "true_type": call.true_type.value,
336
+ "true_severity": call.true_severity.value,
337
+ "arrival_time": call.timestamp,
338
+ "dispatch_time": self.current_time,
339
+ "timeout_window": self.config.call_timeout_minutes,
340
+ "eta": eta,
341
+ "effectiveness": effectiveness,
342
+ "hospital_id": hospital_id,
343
+ }
344
+ )
345
+
346
+ msg = (
347
+ f"Dispatched {unit.unit_id} to {call.call_id}. "
348
+ f"ETA {eta:.1f} min. Unit effectiveness for {call.true_type.value}: "
349
+ f"{effectiveness:.0%}."
350
+ )
351
+ if hospital_id is not None and chosen_hospital is not None:
352
+ msg += f" Destination hospital: {chosen_hospital.name}."
353
+ return 0.02 * effectiveness, msg
354
+
355
+ def classify(self, call_id: str, severity: int) -> Tuple[float, str]:
356
+ call = self._get_active_undispatched_call(call_id)
357
+ if call is None:
358
+ return -0.02, f"Call {call_id} not in pending queue."
359
+ try:
360
+ new_sev = Severity(int(severity))
361
+ except ValueError:
362
+ return -0.02, f"Invalid severity {severity}; must be 1-5."
363
+ old = call.reported_severity
364
+ call.reported_severity = new_sev
365
+ return 0.01, f"Reclassified {call_id} severity from {old} to {new_sev.value}."
366
+
367
+ def callback(self, call_id: str, question: str) -> Tuple[float, str]:
368
+ call = self._get_active_undispatched_call(call_id)
369
+ if call is None:
370
+ return -0.02, f"Call {call_id} not in pending queue."
371
+ # 70% chance the caller clarifies; 30% they're too distressed
372
+ if self.rng.random() < 0.70:
373
+ call.reported_type = call.true_type
374
+ call.reported_severity = call.true_severity
375
+ return (
376
+ 0.02,
377
+ f"Caller for {call.call_id} confirms: this is a {call.true_type.value}, "
378
+ f"severity {call.true_severity.value}.",
379
+ )
380
+ return 0.0, f"Caller for {call.call_id} is too distressed to give clear info."
381
+
382
+ # ------------------------------------------------------------------
383
+ # Lookups
384
+ # ------------------------------------------------------------------
385
+
386
+ def _get_call_by_id(self, call_id: str) -> Optional[EmergencyCall]:
387
+ for c in self.all_calls:
388
+ if c.call_id == call_id:
389
+ return c
390
+ return None
391
+
392
+ def _get_active_undispatched_call(self, call_id: str) -> Optional[EmergencyCall]:
393
+ for c in self.active_calls:
394
+ if c.call_id == call_id and c.dispatched_unit_id is None:
395
+ return c
396
+ return None
397
+
398
+ def get_pending_calls(self) -> List[EmergencyCall]:
399
+ return [c for c in self.active_calls if c.dispatched_unit_id is None]
400
+
401
+ def get_available_units(self) -> List[EmergencyUnit]:
402
+ return [u for u in self.units.values() if u.status == UnitStatus.AVAILABLE]
403
+
404
+ def total_calls(self) -> int:
405
+ return len(self.all_calls)
text_view.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render a DispatchSimulation as a human-readable text view for the LLM agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from models import EmergencyCall, EmergencyUnit, Hospital, UnitStatus
8
+ from simulation import DispatchSimulation
9
+ from utils import calculate_distance, calculate_eta
10
+
11
+ # Maximum number of calls / units / outcomes to show in the text view.
12
+ # Truncation prevents context blow-up on the hard task (30 calls).
13
+ MAX_PENDING_CALLS = 8
14
+ MAX_BUSY_UNITS = 8
15
+ MAX_RECENT_OUTCOMES = 3
16
+
17
+
18
+ def _format_call(call: EmergencyCall, sim: DispatchSimulation) -> List[str]:
19
+ wait = sim.current_time - call.timestamp
20
+ rt = call.reported_type.value if call.reported_type else "unknown"
21
+ rs = call.reported_severity.value if call.reported_severity else "?"
22
+ return [
23
+ f' {call.call_id} [t={call.timestamp}min] "{call.caller_description}"',
24
+ (
25
+ f" location=({call.location.x}, {call.location.y}) "
26
+ f"reported={rt}/sev{rs} waiting={wait}min"
27
+ ),
28
+ ]
29
+
30
+
31
+ def _format_unit(unit: EmergencyUnit, sim: DispatchSimulation, pending: list) -> str:
32
+ base = (
33
+ f" {unit.unit_id:7s} | {unit.unit_type.value:14s} | "
34
+ f"pos=({unit.position.x:.1f}, {unit.position.y:.1f})"
35
+ )
36
+ if pending:
37
+ closest = min(pending, key=lambda c: calculate_distance(unit.position, c.location))
38
+ eta = calculate_eta(unit, closest.location)
39
+ base += f" closest_call_eta={eta:.1f}min ({closest.call_id})"
40
+ return base
41
+
42
+
43
+ def _format_busy_unit(unit: EmergencyUnit) -> str:
44
+ detail = unit.status.value
45
+ if unit.assigned_call_id:
46
+ detail += f" -> {unit.assigned_call_id}"
47
+ if unit.busy_until is not None and unit.status == UnitStatus.ON_SCENE:
48
+ detail += f" (free at t={unit.busy_until}min)"
49
+ return f" {unit.unit_id:7s} | {unit.unit_type.value:14s} | {detail}"
50
+
51
+
52
+ def _format_hospital(hosp: Hospital) -> str:
53
+ specs = []
54
+ if hosp.has_trauma_center:
55
+ specs.append("trauma")
56
+ if hosp.has_cardiac_unit:
57
+ specs.append("cardiac")
58
+ if hosp.has_stroke_unit:
59
+ specs.append("stroke")
60
+ status = "DIVERSION" if hosp.on_diversion else "open"
61
+ return (
62
+ f" {hosp.hospital_id} {hosp.name} ({hosp.position.x},{hosp.position.y}) "
63
+ f"beds={hosp.available_beds}/{hosp.capacity} "
64
+ f"specialties=[{','.join(specs) or 'none'}] status={status}"
65
+ )
66
+
67
+
68
+ def render_dispatch_center(sim: DispatchSimulation, task_name: str) -> str:
69
+ """Pretty-print the current state for the LLM agent."""
70
+ lines: List[str] = []
71
+ lines.append("=== DISPATCHPULSE DISPATCH CENTER ===")
72
+ lines.append(
73
+ f"task={task_name} time={sim.current_time}min/"
74
+ f"{sim.config.time_limit_minutes}min "
75
+ f"scenario={sim.scenario_name}"
76
+ )
77
+ lines.append("")
78
+
79
+ # Pending calls (sorted by reported severity, then arrival time)
80
+ pending = sim.get_pending_calls()
81
+ pending_sorted = sorted(
82
+ pending,
83
+ key=lambda c: (
84
+ c.reported_severity.value if c.reported_severity else 5,
85
+ c.timestamp,
86
+ ),
87
+ )
88
+ lines.append(f"PENDING CALLS ({len(pending_sorted)} total):")
89
+ if not pending_sorted:
90
+ lines.append(" (none)")
91
+ for call in pending_sorted[:MAX_PENDING_CALLS]:
92
+ lines.extend(_format_call(call, sim))
93
+ if len(pending_sorted) > MAX_PENDING_CALLS:
94
+ hidden = len(pending_sorted) - MAX_PENDING_CALLS
95
+ lines.append(f" ... and {hidden} more lower-priority calls")
96
+ lines.append("")
97
+
98
+ # Available units
99
+ available = sim.get_available_units()
100
+ lines.append(f"AVAILABLE UNITS ({len(available)} total):")
101
+ if not available:
102
+ lines.append(" (none — all units busy)")
103
+ for unit in available:
104
+ lines.append(_format_unit(unit, sim, pending_sorted[:MAX_PENDING_CALLS]))
105
+ lines.append("")
106
+
107
+ # Busy units
108
+ busy = [u for u in sim.units.values() if u.status != UnitStatus.AVAILABLE]
109
+ if busy:
110
+ lines.append(f"BUSY UNITS ({len(busy)} total):")
111
+ for unit in busy[:MAX_BUSY_UNITS]:
112
+ lines.append(_format_busy_unit(unit))
113
+ if len(busy) > MAX_BUSY_UNITS:
114
+ lines.append(f" ... and {len(busy) - MAX_BUSY_UNITS} more busy units")
115
+ lines.append("")
116
+
117
+ # Hospitals
118
+ lines.append("HOSPITALS:")
119
+ for hosp in sim.hospitals.values():
120
+ lines.append(_format_hospital(hosp))
121
+ lines.append("")
122
+
123
+ # Recent outcomes
124
+ if sim.completed_calls:
125
+ recent = sim.completed_calls[-MAX_RECENT_OUTCOMES:]
126
+ lines.append("RECENT OUTCOMES:")
127
+ for r in recent:
128
+ mark = "OK " if r["outcome_score"] >= 0.5 else "BAD"
129
+ lines.append(
130
+ f" [{mark}] {r['call_id']} {r['true_type']} sev{r['true_severity']} "
131
+ f"response={r['response_time']:.1f}min outcome={r['outcome_score']:.2f}"
132
+ )
133
+ lines.append("")
134
+
135
+ # Stats footer
136
+ lines.append(
137
+ f"STATS: total={sim.total_calls()} completed={len(sim.completed_calls)} "
138
+ f"timed_out={len(sim.timed_out_calls)} pending={len(pending_sorted)}"
139
+ )
140
+ if sim.episode_done:
141
+ lines.append("EPISODE: DONE")
142
+ return "\n".join(lines)
utils.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helper functions for distance, ETA, lookup tables, caller text generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import List
7
+
8
+ import numpy as np
9
+
10
+ from models import (
11
+ EmergencyCall,
12
+ EmergencyType,
13
+ EmergencyUnit,
14
+ Position,
15
+ Severity,
16
+ UnitType,
17
+ )
18
+
19
+ # ----------------------------------------------------------------------------
20
+ # Geometry
21
+ # ----------------------------------------------------------------------------
22
+
23
+
24
+ def calculate_distance(a: Position, b: Position) -> float:
25
+ """Euclidean distance in km."""
26
+ return math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
27
+
28
+
29
+ def calculate_eta(unit: EmergencyUnit, destination: Position) -> float:
30
+ """ETA in minutes assuming constant speed."""
31
+ distance_km = calculate_distance(unit.position, destination)
32
+ return (distance_km / unit.speed_kmh) * 60.0
33
+
34
+
35
+ # ----------------------------------------------------------------------------
36
+ # Capability lookups
37
+ # ----------------------------------------------------------------------------
38
+
39
+ CAPABLE_UNITS = {
40
+ EmergencyType.CARDIAC_ARREST: [UnitType.ALS_AMBULANCE, UnitType.BLS_AMBULANCE],
41
+ EmergencyType.TRAUMA: [UnitType.ALS_AMBULANCE, UnitType.BLS_AMBULANCE],
42
+ EmergencyType.STROKE: [UnitType.ALS_AMBULANCE, UnitType.BLS_AMBULANCE],
43
+ EmergencyType.FIRE: [UnitType.FIRE_ENGINE],
44
+ EmergencyType.BREATHING: [UnitType.ALS_AMBULANCE, UnitType.BLS_AMBULANCE],
45
+ EmergencyType.MINOR_INJURY: [UnitType.BLS_AMBULANCE, UnitType.ALS_AMBULANCE],
46
+ EmergencyType.MENTAL_HEALTH: [UnitType.POLICE, UnitType.ALS_AMBULANCE],
47
+ }
48
+
49
+ OPTIMAL_UNIT = {
50
+ EmergencyType.CARDIAC_ARREST: UnitType.ALS_AMBULANCE,
51
+ EmergencyType.TRAUMA: UnitType.ALS_AMBULANCE,
52
+ EmergencyType.STROKE: UnitType.ALS_AMBULANCE,
53
+ EmergencyType.FIRE: UnitType.FIRE_ENGINE,
54
+ EmergencyType.BREATHING: UnitType.ALS_AMBULANCE,
55
+ EmergencyType.MINOR_INJURY: UnitType.BLS_AMBULANCE,
56
+ EmergencyType.MENTAL_HEALTH: UnitType.POLICE,
57
+ }
58
+
59
+
60
+ def get_capable_units(emergency_type: EmergencyType) -> List[UnitType]:
61
+ return CAPABLE_UNITS[emergency_type]
62
+
63
+
64
+ def get_optimal_unit(emergency_type: EmergencyType) -> UnitType:
65
+ return OPTIMAL_UNIT[emergency_type]
66
+
67
+
68
+ # ----------------------------------------------------------------------------
69
+ # Caller description templates
70
+ # ----------------------------------------------------------------------------
71
+
72
+ CALLER_TEMPLATES = {
73
+ EmergencyType.CARDIAC_ARREST: [
74
+ "Man collapsed on the sidewalk, he's not breathing! Please hurry!",
75
+ "My husband just grabbed his chest and fell down, he's turning blue!",
76
+ "Someone collapsed at the bus stop, no pulse, people are trying CPR!",
77
+ "Elderly woman found unconscious in her apartment, not responding!",
78
+ ],
79
+ EmergencyType.TRAUMA: [
80
+ "Car accident on the main road, driver is bleeding from the head!",
81
+ "Construction worker fell from scaffolding, conscious but can't move his legs!",
82
+ "Two-wheeler hit a pedestrian, person on the road with a broken leg!",
83
+ "Fight broke out, one person stabbed, lots of blood!",
84
+ ],
85
+ EmergencyType.STROKE: [
86
+ "My mother's face is drooping on one side and she can't speak properly!",
87
+ "Colleague suddenly can't move his right arm, speech is slurred!",
88
+ "Old man at the temple suddenly confused, can't walk straight!",
89
+ ],
90
+ EmergencyType.FIRE: [
91
+ "Kitchen fire in our apartment, smoke everywhere, we're on the 3rd floor!",
92
+ "Warehouse on fire near the industrial area, flames visible from outside!",
93
+ "Electrical fire in the office building, fire alarm going off!",
94
+ "Grass fire spreading toward houses near the park!",
95
+ ],
96
+ EmergencyType.MINOR_INJURY: [
97
+ "I slipped on the stairs and twisted my ankle, it's swollen.",
98
+ "Small cut on my hand from a knife, bleeding a bit but not too bad.",
99
+ "Child fell off bicycle, scraped knee and elbow, crying but alert.",
100
+ "Bumped my head on a low beam, small bump, feeling a bit dizzy.",
101
+ ],
102
+ EmergencyType.BREATHING: [
103
+ "My asthma is really bad, can't catch my breath, inhaler isn't working!",
104
+ "Allergic reaction, my throat is swelling up, hard to breathe!",
105
+ "Elderly patient with COPD, oxygen levels dropping, very short of breath!",
106
+ ],
107
+ EmergencyType.MENTAL_HEALTH: [
108
+ "My neighbor is threatening to hurt himself, he's very distressed!",
109
+ "Person on the building ledge, seems very agitated, won't come down!",
110
+ "Family member having a severe panic attack, can't calm down, hyperventilating!",
111
+ ],
112
+ }
113
+
114
+ MISREPORT_TEMPLATES = {
115
+ (EmergencyType.MENTAL_HEALTH, EmergencyType.CARDIAC_ARREST): [
116
+ "Someone is clutching their chest and can't breathe! I think it's a heart attack!",
117
+ ],
118
+ (EmergencyType.MINOR_INJURY, EmergencyType.TRAUMA): [
119
+ "Bad accident! Person is hurt, there's some blood!",
120
+ ],
121
+ (EmergencyType.BREATHING, EmergencyType.STROKE): [
122
+ "My father can't talk properly and is struggling, please come fast!",
123
+ ],
124
+ (EmergencyType.CARDIAC_ARREST, EmergencyType.MENTAL_HEALTH): [
125
+ "He's holding his chest and crying, very upset, hyperventilating!",
126
+ ],
127
+ (EmergencyType.TRAUMA, EmergencyType.MINOR_INJURY): [
128
+ "Someone fell off a ladder, looks like a small bump but they're complaining a lot.",
129
+ ],
130
+ }
131
+
132
+
133
+ def generate_caller_text(
134
+ true_type: EmergencyType,
135
+ reported_type: EmergencyType,
136
+ rng: np.random.RandomState,
137
+ ) -> str:
138
+ """Generate a caller description string from templates."""
139
+ if true_type != reported_type:
140
+ key = (true_type, reported_type)
141
+ if key in MISREPORT_TEMPLATES:
142
+ return rng.choice(MISREPORT_TEMPLATES[key])
143
+ return rng.choice(CALLER_TEMPLATES[reported_type])
144
+
145
+
146
+ # ----------------------------------------------------------------------------
147
+ # Severity helpers
148
+ # ----------------------------------------------------------------------------
149
+
150
+
151
+ def severity_from_int(s: int) -> Severity:
152
+ return Severity(int(s))