Spaces:
Sleeping
Sleeping
Commit ·
07473e9
1
Parent(s): 50e3063
Add DispatchSimulation engine, geometry helpers, caller text templates, and observation renderer
Browse files- scenario_loader.py +36 -0
- simulation.py +405 -0
- text_view.py +142 -0
- 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))
|