des_hems
1import math 2import os, simpy 3from random import expovariate, uniform 4from sim_tools.time_dependent import NSPPThinning 5import pandas as pd 6# from random import expovariate 7from utils import Utils, SeededDistribution 8from class_patient import Patient 9# Revised class for HEMS availability 10from class_hems_availability import HEMSAvailability 11from class_hems import HEMS 12from class_ambulance import Ambulance 13from datetime import timedelta 14import warnings 15import numpy as np 16from math import floor 17warnings.filterwarnings("ignore", category=RuntimeWarning) 18# import all distributions 19import ast 20from numpy.random import SeedSequence 21from typing import List, Dict, Tuple 22from numpy.random import SeedSequence, default_rng 23import random 24 25import logging 26logging.basicConfig(filename='log.txt', filemode="w", level=logging.DEBUG, format='') 27 28class DES_HEMS: 29 """ 30 # The DES_HEMS class 31 32 This class contains everything require to undertake a single DES run for multiple patients 33 over the specified duration. 34 35 NOTE: The unit of sim is minutes 36 37 """ 38 39 def __init__(self, 40 run_number: int, 41 sim_duration: int, 42 warm_up_duration: int, 43 sim_start_date: str, 44 amb_data: bool, 45 random_seed: int, 46 demand_increase_percent: float, 47 activity_duration_multiplier: float, 48 print_debug_messages: bool): 49 50 self.run_number = run_number + 1 # Add 1 so we don't have a run_number 0 51 self.sim_duration = sim_duration 52 self.warm_up_duration = warm_up_duration 53 self.sim_start_date = sim_start_date 54 55 self.random_seed = random_seed 56 self.random_seed_sequence = SeedSequence(self.random_seed) 57 58 self.print_debug_messages = print_debug_messages 59 60 self.utils = Utils( 61 master_seed=self.random_seed_sequence.spawn(1)[0], 62 print_debug_messages=self.print_debug_messages 63 ) 64 65 self.utils.setup_seeds() 66 67 self.demand_increase_percent = demand_increase_percent 68 69 # Option for sampling calls per day by season or quarter 70 self.daily_calls_by_quarter_or_season = 'quarter' 71 72 # Option to include/exclude ambulance service cases in addition to HEMS 73 self.amb_data = amb_data 74 #self.debug(f"Ambulance data values is {self.amb_data}") 75 76 self.all_results_location = self.utils.ALL_RESULTS_CSV 77 self.run_results_location = self.utils.RUN_RESULTS_CSV 78 79 self.env = simpy.Environment() 80 self.patient_counter = 0 81 self.calls_today = 0 82 self.new_day = pd.to_datetime("1900-01-01").date 83 self.new_hour = -1 84 85 self.hems_resources = HEMSAvailability(env=self.env, 86 sim_start_date=sim_start_date, 87 sim_duration=sim_duration, 88 print_debug_messages=self.print_debug_messages, 89 master_seed=self.random_seed_sequence, 90 utility=self.utils 91 ) 92 93 # Set up empty list to store results prior to conversion to dataframe 94 self.results_list = [] 95 96 # Set up data frame to capture time points etc. during the simulation 97 # We might not need all of these, but simpler to capture them all for now. 98 99 # This might be better as a separate 'data collection' class of some sort. 100 # I am thinking that each resource will need its own entry for any given 101 # patient to account for dual response (ambulance service and HEMS) 102 # stand downs etc. 103 self.results_df = None 104 105 # self.inter_arrival_times_df = pd.read_csv('distribution_data/inter_arrival_times.csv') 106 107 self.activity_duration_multiplier = activity_duration_multiplier 108 109 # self.seeded_dists = self.utils.build_seeded_distributions( 110 # self.utils.activity_time_distr, 111 # master_seed=self.random_seed 112 # ) 113 114 def debug(self, message: str): 115 if self.print_debug_messages: 116 logging.debug(message) 117 #print(message) 118 119 120 def calls_per_hour(self, quarter: int) -> dict: 121 """ 122 Function to return a dictionary of keys representing the hour of day 123 and value representing the number of calls in that hour 124 """ 125 126 # self.debug(f"There are going to be {self.calls_today} calls today and the current hour is {current_hour}") 127 128 hourly_activity = self.utils.hourly_arrival_by_qtr_probs_df 129 hourly_activity_for_qtr = hourly_activity[hourly_activity['quarter'] == quarter][['hour','proportion']] 130 131 calls_in_hours = [] 132 133 for i in range(0, self.calls_today): 134 hour = pd.Series.sample(hourly_activity_for_qtr['hour'], weights = hourly_activity_for_qtr['proportion'], 135 random_state=self.utils.rngs["calls_per_hour"]).iloc[0] 136 #self.debug(f"Chosen hour is {hour}") 137 calls_in_hours.append(hour) 138 139 calls_in_hours.sort() 140 141 #self.debug(calls_in_hours) 142 143 d = {} 144 145 hours, counts = np.unique(calls_in_hours, return_counts=True) 146 147 for i in range(len(hours)): 148 d[hours[i]] = counts[i] 149 150 return d 151 152 def predetermine_call_arrival(self, current_hour: int, quarter: int) -> list: 153 """ 154 Function to determine the number of calls in 155 24 hours and the inter-arrival rate for calls in that period 156 Returns a list of times that should be used in the yield timeout statement 157 in a patient generator 158 159 """ 160 161 hourly_activity = self.utils.hourly_arrival_by_qtr_probs_df 162 hourly_activity_for_qtr = hourly_activity[hourly_activity['quarter'] == quarter][['hour','proportion']] 163 164 d = self.calls_per_hour(quarter) 165 166 ia_time = [] 167 168 for index, row in hourly_activity_for_qtr.iterrows(): 169 hour = row['hour'] 170 if hour >= current_hour and hour in d: 171 count = d[hour] 172 if count > 0: 173 scale = 60 / count # mean of exponential = 1 / rate 174 calc_ia_time = self.utils.rngs["predetermine_call_arrival"].exponential(scale=scale) 175 tmp_ia_time = ((hour - current_hour) * 60) + calc_ia_time 176 current_hour += floor(tmp_ia_time / 60) 177 ia_time.append(tmp_ia_time) 178 179 return ia_time 180 181 182 def generate_calls(self): 183 """ 184 **Call generator** 185 186 Generate calls (and patients) until current time equals sim_duration + warm_up_duration. 187 This method calculates number of calls per day and then distributes them according to distributions determined 188 from historic data 189 190 """ 191 192 while self.env.now < (self.sim_duration + self.warm_up_duration): 193 # Get current day of week and hour of day 194 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 195 196 if(self.new_hour != hod): 197 self.new_hour = hod 198 199 #self.debug("new hour") 200 201 # If it is a new day, need to calculate how many calls 202 # in the next 24 hours 203 if(self.new_day != current_dt.date()): 204 # self.debug("It's a new day") 205 # self.debug(dow) 206 # self.debug(f"{self.new_day} and {current_dt.date}") 207 208 # Now have additional option of determining calls per day by quarter instead of season 209 #self.calls_today = int(self.utils.inc_per_day(qtr, self.daily_calls_by_quarter_or_season) * (self.demand_increase_percent)) 210 211 # Try with actual sampled values instead 212 self.calls_today = int(self.utils.inc_per_day_samples(qtr, self.daily_calls_by_quarter_or_season) * (self.demand_increase_percent)) 213 214 # self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today") 215 #self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today") 216 217 self.new_day = current_dt.date() 218 219 ia_dict = {} 220 ia_dict = self.calls_per_hour(qtr) 221 222 #self.debug(ia_dict) 223 224 # Also run scripts to check HEMS resources to see whether they are starting/finishing service 225 yield self.env.process(self.hems_resources.daily_servicing_check(current_dt, hod, month)) 226 227 228 if self.calls_today > 0: 229 if hod in ia_dict.keys(): 230 minutes_elapsed = 0 231 for i in range(0, ia_dict[hod]): 232 if minutes_elapsed < 59: 233 # Determine remaining time and sample a wait time 234 remaining_minutes = 59 - minutes_elapsed 235 # wait_time = random.randint(0, remaining_minutes) 236 wait_time = int(self.utils.rngs["call_iat"].integers(0, remaining_minutes+1)) 237 238 yield self.env.timeout(wait_time) 239 minutes_elapsed += wait_time 240 241 self.env.process(self.generate_patient(dow, hod, weekday, month, qtr, current_dt)) 242 243 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 244 245 246 next_hr = current_dt.floor('h') + pd.Timedelta('1h') 247 yield self.env.timeout(math.ceil(pd.to_timedelta(next_hr - current_dt).total_seconds() / 60)) 248 249 #self.debug('Out of loop') 250 else: 251 # Skip to tomorrow 252 253 self.debug('Skip to tomorrow') 254 255 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 256 next_day = current_dt.floor('d') + pd.Timedelta(days=1) 257 self.debug("next day is {next_day}") 258 yield self.env.timeout(math.ceil(pd.to_timedelta(next_hr - current_dt).total_seconds() / 60)) 259 260 261 def generate_patient(self, dow, hod, weekday, month, qtr, current_dt): 262 263 self.patient_counter += 1 264 265 # Create a new caller/patient 266 pt = Patient(self.patient_counter) 267 268 # Update patient instance with time-based values so the current time is known 269 pt.day = dow 270 pt.hour = hod 271 pt.weekday = weekday 272 pt.month = month 273 pt.qtr = qtr 274 pt.current_dt = current_dt 275 276 #self.debug(f"Patient {pt.id} incident date: {pt.current_dt}") 277 278 # Update patient instance with age, sex, AMPDS card, whether they are a HEMS' patient and if so, the HEMS result, 279 pt.ampds_card = self.utils.ampds_code_selection(pt.hour) 280 #self.debug(f"AMPDS card is {pt.ampds_card}") 281 pt.age = self.utils.age_sampling(pt.ampds_card, 115) 282 pt.sex = self.utils.sex_selection(pt.ampds_card) 283 hems_cc_or_ec = self.utils.care_category_selection(pt.ampds_card) 284 pt.hems_cc_or_ec = hems_cc_or_ec 285 self.debug(f"{pt.current_dt}: {pt.id} Pt allocated to {pt.hems_cc_or_ec} from AMPDS {pt.ampds_card}") 286 287 pt.pt_outcome = self.utils.pt_outcome_selection(pt.hems_cc_or_ec, int(qtr)) 288 self.debug(f"{pt.current_dt}: {pt.id} Pt allocated to patient outcome: {pt.pt_outcome}") 289 290 self.add_patient_result_row(pt, "arrival", "arrival_departure") 291 292 if self.amb_data: 293 # TODO: We'll need the logic to decide whether it is an ambulance or HEMS case 294 # if ambulance data is being collected too. 295 self.debug("Ambulance case") 296 pt.hems_case = 1 if self.utils.rngs["hems_case"].uniform(0, 1) <= 0.5 else pt.hems_case == 0 297 else: 298 pt.hems_case = 1 299 300 if pt.hems_case == 1: 301 #self.debug(f"Going to callsign_group_selection with hour {pt.hour} and AMPDS {pt.ampds_card}") 302 # pt.hems_pref_callsign_group = self.utils.callsign_group_selection(pt.ampds_card) 303 # pt.hems_pref_callsign_group = self.utils.callsign_group_selection(pt.hems_cc_or_ec) 304 pt.hems_pref_callsign_group = self.utils.callsign_group_selection() 305 #self.debug(f"Callsign is {pt.hems_pref_callsign_group}") 306 307 # Some % of calls have a helicopter benefit 308 # Default to y for all patients 309 helicopter_benefit = 'n' 310 311 # Update for REG patients based on historically observed patterns 312 with open("distribution_data/proportion_jobs_heli_benefit.txt", "r") as file: 313 expected_prop_heli_benefit_jobs_reg = float(file.read().strip()) 314 if pt.hems_cc_or_ec == 'REG': 315 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_reg"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_reg else 'n' 316 317 # Update for CC patients based on historically observed patterns 318 with open("distribution_data/proportion_jobs_heli_benefit_cc.txt", "r") as file: 319 expected_prop_heli_benefit_jobs_cc = float(file.read().strip()) 320 if pt.hems_cc_or_ec == 'CC': 321 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_cc"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_cc else 'n' 322 323 # Update for EC patients based on historically observed patterns 324 with open("distribution_data/proportion_jobs_heli_benefit_ec.txt", "r") as file: 325 expected_prop_heli_benefit_jobs_ec = float(file.read().strip()) 326 if pt.hems_cc_or_ec == 'EC': 327 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_ec"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_ec else 'n' 328 329 # Following conversation with HT 21/5 330 # Also count as having had a helicopter benefit if the patient is conveyed by HEMS 331 # This is consistent with how things are done in the golden codes paper 332 # https://static-content.springer.com/esm/art%3A10.1186%2Fs13049-023-01094-w/MediaObjects/13049_2023_1094_MOESM1_ESM.pdf 333 # while having not always been coded in the historic dataset 334 # (TODO: SR 30/5 I have commented this out for now and we should revisit this at some point - 335 # as we might be slightly overestimating heli benefit patients as a result? These need to be 336 # more closely/cleverly linked or just covered by the historic dataset instead) 337 338 if pt.hems_result == "Patient Conveyed by HEMS": 339 helicopter_benefit = 'y' 340 341 pt.hems_helicopter_benefit = helicopter_benefit 342 self.add_patient_result_row(pt, pt.hems_cc_or_ec, "patient_care_category") 343 self.add_patient_result_row(pt, pt.hems_helicopter_benefit, "patient_helicopter_benefit") 344 345 # pt.hems_pref_vehicle_type = self.utils.vehicle_type_selection(pt.hems_pref_callsign_group) 346 pt.hems_pref_vehicle_type = self.utils.vehicle_type_selection_qtr(pt.hems_pref_callsign_group, int(qtr)) 347 # pt.hems_pref_vehicle_type = 'helicopter' 348 #pt.hems_pref_callsign_group = '70' 349 #pt.hems_helicopter_benefit = 'y' 350 351 self.add_patient_result_row(pt, pt.hems_pref_callsign_group, "resource_preferred_resource_group") 352 self.add_patient_result_row(pt, pt.hems_pref_vehicle_type, "resource_preferred_vehicle_type") 353 354 if (pt.hems_cc_or_ec == 'CC' or pt.hems_cc_or_ec == 'EC') and self.utils.rngs["know_cc_ec_benefit"].uniform(0, 1) <= 0.5: 355 hems_res_list: list[HEMS|None, str, HEMS|None] = yield self.hems_resources.allocate_resource(pt) 356 else: 357 # Separate function to determine HEMS resource based on the preferred callsign group 358 # (which is sampled from historical data), the preferred vehicle type 359 hems_res_list: list[HEMS|None, str, HEMS|None] = yield self.hems_resources.allocate_regular_resource(pt) 360 361 362 self.debug(f"{pt.id} hems_res_list: {hems_res_list}") 363 364 hems_allocation = hems_res_list[0] 365 366 # This will either contain the other resource in a callsign_group and HEMS category (EC/CC) or None 367 hems_group_resource_allocation = hems_res_list[2] 368 369 self.add_patient_result_row(pt, hems_res_list[1], "resource_preferred_outcome") 370 371 if hems_allocation is not None: 372 #self.debug(f"allocated {hems_allocation.callsign}") 373 374 self.env.process(self.patient_journey(hems_allocation, pt, hems_group_resource_allocation)) 375 else: 376 #self.debug(f"{pt.current_dt} No HEMS resource available - non-DAAT land crew sent") 377 self.env.process(self.patient_journey(None, pt, None)) 378 379 380 def patient_journey(self, hems_res: HEMS|None, patient: Patient, secondary_hems_res: HEMS|None): 381 """ 382 Send patient on their journey! 383 """ 384 385 #self.debug(f"Patient journey triggered for {patient.id}") 386 #self.debug(f"patient journey Time: {self.env.now}") 387 # self.debug(hems_res.callsign) 388 389 try: 390 patient_enters_sim = self.env.now 391 392 # Note that 'add_patient_result_row' does its own check for whether it's currently within 393 # the warm-up period, so this does not need to be checked manually when adding result 394 # rows in this section 395 # not_in_warm_up_period = False if self.env.now < self.warm_up_duration else True 396 397 # Add variables for quick determination of whether certain parts of the process should 398 # be included per case 399 hems_case = True if patient.hems_case == 1 else False 400 hems_avail = True if hems_res is not None else False 401 402 if not hems_avail: 403 self.debug(f"Patient {patient.id}: No HEMS available") 404 self.add_patient_result_row(patient, "No HEMS available", "queue") 405 self.add_patient_result_row(patient, "No Resource Available", "resource_request_outcome") 406 407 # if hems_avail 408 else: 409 410 # Record selected vehicle type and callsign group in patient object 411 patient.hems_vehicle_type = hems_res.vehicle_type 412 patient.hems_registration = hems_res.registration 413 patient.callsign = hems_res.callsign 414 415 self.add_patient_result_row(patient, patient.hems_vehicle_type, "selected_vehicle_type") 416 self.add_patient_result_row(patient, patient.hems_callsign_group, "selected_callsign_group") 417 418 #patient.hems_result = self.utils.hems_result_by_callsign_group_and_vehicle_type_selection(patient.hems_callsign_group, patient.hems_vehicle_type) 419 #self.debug(f"{patient.hems_cc_or_ec} and {patient.hems_helicopter_benefit}") 420 #patient.hems_result = self.utils.hems_result_by_care_category_and_helicopter_benefit_selection(patient.hems_cc_or_ec, patient.hems_helicopter_benefit) 421 422 # Determine outcome 423 # If we know a resource has been allocated, we can determine the output from historical patterns 424 if hems_res: 425 patient.hems_result = self.utils.hems_results_by_patient_outcome_and_time_of_day_and_quarter_and_vehicle_type_and_callsign_group_probs( 426 patient.pt_outcome, 427 int(patient.qtr), 428 patient.hems_vehicle_type, 429 patient.hems_callsign_group, 430 int(patient.hour) 431 ) 432 else: 433 # Default to what is recorded when no resource sent 434 patient.hems_result = "Unknown" 435 436 self.debug(f"{patient.current_dt}: PT_ID:{patient.id} Pt allocated to HEMS result: {patient.hems_result}") 437 self.add_patient_result_row(patient, "HEMS Resource Available", "resource_request_outcome") 438 self.add_patient_result_row(patient, hems_res.callsign, "resource_use") 439 self.debug(f"{patient.current_dt} Patient {patient.id} (preferred callsign group {patient.hems_pref_callsign_group}, preferred resource type {patient.hems_pref_vehicle_type}) sent resource {hems_res.callsign}") 440 self.add_patient_result_row(patient, hems_res.callsign, "callsign_group_resource_use") 441 442 # Check if HEMS result indicates that resource stood down before going mobile or en route 443 no_HEMS_at_scene = True if patient.hems_result in ["Stand Down"] else False 444 # Check if HEMS result indicates no leaving scene/at hospital times 445 no_HEMS_hospital = ( 446 True if patient.hems_result 447 in ["Stand Down", "Landed but no patient contact", "Patient Treated but not conveyed by HEMS"] 448 else False 449 ) 450 451 #self.debug('Inside hems_avail') 452 if self.amb_data: 453 ambulance = Ambulance() 454 455 patient.time_in_sim = self.env.now - patient_enters_sim 456 457 if hems_case and hems_avail: 458 #self.debug(f"Adding result row with csg {patient.hems_callsign_group}") 459 self.add_patient_result_row(patient, "HEMS call start", "queue") 460 461 if self.amb_data: 462 self.add_patient_result_row(patient, "AMB call start", "queue") 463 464 # Allocation time -------------- 465 # Allocation will always take place if a resource is found 466 467 if hems_case and hems_avail: 468 # Calculate min and max permitted times. 469 allocation_time = ( 470 self.utils.activity_time(patient.hems_vehicle_type, 'time_allocation') 471 * self.activity_duration_multiplier 472 ) 473 self.add_patient_result_row(patient, allocation_time, 'time_allocation') 474 self.add_patient_result_row(patient, "HEMS allocated to call", "queue") 475 #self.debug(f"Vehicle type {patient.hems_vehicle_type} and allocation time is {allocation_time}") 476 yield self.env.timeout(allocation_time) 477 478 if self.amb_data: 479 #self.debug('Ambulance allocation time') 480 yield self.env.timeout(180) 481 482 patient.time_in_sim = self.env.now - patient_enters_sim 483 484 # Mobilisation time --------------- 485 486 # Calculate mobile to time at scene (or stood down before) 487 if hems_case and hems_avail: 488 mobile_time = ( 489 self.utils.activity_time(patient.hems_vehicle_type, 'time_mobile') 490 * self.activity_duration_multiplier 491 ) 492 self.add_patient_result_row(patient, mobile_time, 'time_mobile') 493 self.add_patient_result_row(patient, "HEMS mobile", "queue") 494 yield self.env.timeout(mobile_time) 495 496 if self.amb_data: 497 # Determine allocation time for ambulance 498 # Yield until done 499 #self.debug('Ambulance time to going mobile') 500 self.debug('Ambulance mobile') 501 yield self.env.timeout(1) 502 503 patient.time_in_sim = self.env.now - patient_enters_sim 504 505 # On scene --------------- 506 507 if hems_case and hems_avail and not no_HEMS_at_scene: 508 tts_time = ( 509 self.utils.activity_time(patient.hems_vehicle_type, 'time_to_scene') 510 * self.activity_duration_multiplier 511 ) 512 self.add_patient_result_row(patient, tts_time, "time_to_scene") 513 self.add_patient_result_row(patient, "HEMS on scene", "queue") 514 yield self.env.timeout(tts_time) 515 516 if self.amb_data: 517 # Determine allocation time for ambulance 518 # Yield until done 519 #self.debug('Ambulance time to scene') 520 yield self.env.timeout(20) 521 522 patient.time_in_sim = self.env.now - patient_enters_sim 523 524 if self.amb_data: 525 self.debug('Ambulance stand down en route') 526 527 528 # Leaving scene ------------ 529 530 if hems_case and hems_avail and not no_HEMS_at_scene: 531 tos_time = ( 532 self.utils.activity_time(patient.hems_vehicle_type, 'time_on_scene') 533 * self.activity_duration_multiplier 534 ) 535 self.add_patient_result_row(patient, tos_time, "time_on_scene") 536 yield self.env.timeout(tos_time) 537 538 if no_HEMS_hospital: 539 self.add_patient_result_row(patient, f"HEMS {patient.hems_result.lower()}", "queue") 540 else: 541 self.add_patient_result_row(patient, "HEMS leaving scene", "queue") 542 543 if self.amb_data: 544 #self.debug('Ambulance on scene duration') 545 yield self.env.timeout(120) 546 self.debug('Ambulance leaving scene time') 547 548 patient.time_in_sim = self.env.now - patient_enters_sim 549 550 # Arrived destination time ------------ 551 552 if hems_case and hems_avail and not no_HEMS_hospital: 553 travel_time = self.utils.activity_time(patient.hems_vehicle_type, 'time_to_hospital') * self.activity_duration_multiplier 554 self.add_patient_result_row(patient, travel_time, 'time_to_hospital') 555 self.add_patient_result_row(patient, "HEMS arrived destination", "queue") 556 yield self.env.timeout(travel_time) 557 558 if self.amb_data: 559 #self.debug('Ambulance travel time') 560 yield self.env.timeout(30) 561 self.debug('Ambulance at destination time') 562 563 patient.time_in_sim = self.env.now - patient_enters_sim 564 565 # Handover time --------------- 566 567 # Not currently available 568 569 # Clear time ------------ 570 571 if hems_case and hems_avail: 572 clear_time = ( 573 self.utils.activity_time(patient.hems_vehicle_type, 'time_to_clear') 574 * self.activity_duration_multiplier 575 ) 576 self.add_patient_result_row(patient, clear_time, 'time_to_clear') 577 self.add_patient_result_row(patient, "HEMS clear", "queue") 578 yield self.env.timeout(clear_time) 579 580 if self.amb_data: 581 #self.debug('Ambulance clear time') 582 yield self.env.timeout(60) 583 584 patient.time_in_sim = self.env.now - patient_enters_sim 585 586 if self.amb_data: 587 self.debug('Ambulance clear time') 588 589 #self.debug(f"Depart for patient {patient.id} on run {self.run_number}") 590 591 self.add_patient_result_row(patient, "depart", "arrival_departure") 592 finally: 593 # Always return the resource at the end of the patient journey. 594 if hems_res is not None: 595 self.hems_resources.return_resource(hems_res, secondary_hems_res) 596 self.debug(f"Attempting to return {hems_res} and {secondary_hems_res} to resource store") 597 self.add_patient_result_row(patient, hems_res.callsign, "resource_use_end") 598 599 def add_patient_result_row(self, 600 patient: Patient, 601 time_type: str, 602 event_type: str, 603 **kwargs) -> None : 604 """ 605 Convenience function to create a row of data for the results table 606 607 """ 608 609 results = { 610 "P_ID" : patient.id, 611 "run_number" : self.run_number, 612 "time_type" : time_type, # e.g. mobile, at scene, leaving scene etc. 613 "event_type" : event_type, # for animation: arrival_departure, queue, resource_use, resource_use_end 614 "timestamp" : self.env.now, 615 "timestamp_dt" : self.sim_start_date + timedelta(minutes=self.env.now), 616 "day" : patient.day, 617 "hour" : patient.hour, 618 "weekday" : patient.weekday, 619 "month" : patient.month, 620 "qtr" : patient.qtr, 621 "registration" : patient.hems_registration, # registration of allocated/attending vehicle 622 "callsign" : patient.callsign, # callsign of allocated/attending vehicle 623 "callsign_group" : patient.hems_callsign_group, # callsign group of allocated/attending vehicle 624 "vehicle_type" : patient.hems_vehicle_type, # vehicle type (car/helicopter) of allocated/attending vehicle 625 "hems_res_category" : patient.hems_category, 626 "ampds_card" : patient.ampds_card, 627 "age" : patient.age, 628 "sex" : patient.sex, 629 "care_cat" : patient.hems_cc_or_ec, 630 "heli_benefit" : patient.hems_helicopter_benefit, 631 "hems_result" : patient.hems_result, 632 "outcome" : patient.pt_outcome, 633 "hems_reg" : patient.hems_registration 634 } 635 636 #self.debug(results) 637 638 # Add any additional items passed in **kwargs 639 for key, value in kwargs.items(): 640 results[key] = value 641 642 if self.env.now >= self.warm_up_duration: 643 self.store_patient_results(results) 644 645 646 def store_patient_results(self, results: dict) -> None: 647 """ 648 Adds a row of data to the Class' `result_df` dataframe 649 """ 650 651 self.results_list.append(results) 652 653 654 def convert_results_to_df(self, results: dict) -> None: 655 self.results_df = pd.DataFrame(results) 656 try: 657 self.results_df.set_index("P_ID", inplace=True) 658 # TODO - Improve error handling here 659 except KeyError: 660 pass 661 662 def write_all_results(self) -> None: 663 """ 664 Writes the content of `result_df` to a csv file 665 """ 666 # https://stackoverflow.com/a/30991707/3650230 667 668 # Check if file exists...if it does, append data, otherwise create a new file with headers matching 669 # the column names of `results_df` 670 if not os.path.isfile(self.all_results_location): 671 self.results_df.to_csv(self.all_results_location, header='column_names') 672 else: # else it exists so append without writing the header 673 self.results_df.to_csv(self.all_results_location, mode='a', header=False) 674 675 def write_run_results(self) -> None: 676 """ 677 Writes the content of `result_dfs` to csv files that contains only the results from the 678 single run 679 680 Note that this cannot be done in a similar manner to write_all_results due to the impacts of 681 the parallisation approach that is taken with joblib - depending on process timings it can lead to 682 not all 683 """ 684 685 self.results_df.to_csv(f"{Utils.RESULTS_FOLDER}/output_run_{self.run_number}.csv", header='column_names') 686 687 def run(self) -> None: 688 """ 689 Function to start the simulation. 690 691 """ 692 self.debug(f"HEMS class initialised with the following: run {self.run_number}, duration {self.sim_duration}, warm-up {self.warm_up_duration}, start date {self.sim_start_date}, demand increase multiplier {self.demand_increase_percent}, activity duration multiplier {self.activity_duration_multiplier}") 693 694 # Start entity generators 695 self.env.process(self.generate_calls()) 696 697 # Run simulation 698 self.env.run(until=(self.sim_duration + self.warm_up_duration)) 699 700 # Convert results to a dataframe 701 self.convert_results_to_df(results=self.results_list) 702 703 # Write run results to file 704 self.write_all_results() 705 self.write_run_results()
29class DES_HEMS: 30 """ 31 # The DES_HEMS class 32 33 This class contains everything require to undertake a single DES run for multiple patients 34 over the specified duration. 35 36 NOTE: The unit of sim is minutes 37 38 """ 39 40 def __init__(self, 41 run_number: int, 42 sim_duration: int, 43 warm_up_duration: int, 44 sim_start_date: str, 45 amb_data: bool, 46 random_seed: int, 47 demand_increase_percent: float, 48 activity_duration_multiplier: float, 49 print_debug_messages: bool): 50 51 self.run_number = run_number + 1 # Add 1 so we don't have a run_number 0 52 self.sim_duration = sim_duration 53 self.warm_up_duration = warm_up_duration 54 self.sim_start_date = sim_start_date 55 56 self.random_seed = random_seed 57 self.random_seed_sequence = SeedSequence(self.random_seed) 58 59 self.print_debug_messages = print_debug_messages 60 61 self.utils = Utils( 62 master_seed=self.random_seed_sequence.spawn(1)[0], 63 print_debug_messages=self.print_debug_messages 64 ) 65 66 self.utils.setup_seeds() 67 68 self.demand_increase_percent = demand_increase_percent 69 70 # Option for sampling calls per day by season or quarter 71 self.daily_calls_by_quarter_or_season = 'quarter' 72 73 # Option to include/exclude ambulance service cases in addition to HEMS 74 self.amb_data = amb_data 75 #self.debug(f"Ambulance data values is {self.amb_data}") 76 77 self.all_results_location = self.utils.ALL_RESULTS_CSV 78 self.run_results_location = self.utils.RUN_RESULTS_CSV 79 80 self.env = simpy.Environment() 81 self.patient_counter = 0 82 self.calls_today = 0 83 self.new_day = pd.to_datetime("1900-01-01").date 84 self.new_hour = -1 85 86 self.hems_resources = HEMSAvailability(env=self.env, 87 sim_start_date=sim_start_date, 88 sim_duration=sim_duration, 89 print_debug_messages=self.print_debug_messages, 90 master_seed=self.random_seed_sequence, 91 utility=self.utils 92 ) 93 94 # Set up empty list to store results prior to conversion to dataframe 95 self.results_list = [] 96 97 # Set up data frame to capture time points etc. during the simulation 98 # We might not need all of these, but simpler to capture them all for now. 99 100 # This might be better as a separate 'data collection' class of some sort. 101 # I am thinking that each resource will need its own entry for any given 102 # patient to account for dual response (ambulance service and HEMS) 103 # stand downs etc. 104 self.results_df = None 105 106 # self.inter_arrival_times_df = pd.read_csv('distribution_data/inter_arrival_times.csv') 107 108 self.activity_duration_multiplier = activity_duration_multiplier 109 110 # self.seeded_dists = self.utils.build_seeded_distributions( 111 # self.utils.activity_time_distr, 112 # master_seed=self.random_seed 113 # ) 114 115 def debug(self, message: str): 116 if self.print_debug_messages: 117 logging.debug(message) 118 #print(message) 119 120 121 def calls_per_hour(self, quarter: int) -> dict: 122 """ 123 Function to return a dictionary of keys representing the hour of day 124 and value representing the number of calls in that hour 125 """ 126 127 # self.debug(f"There are going to be {self.calls_today} calls today and the current hour is {current_hour}") 128 129 hourly_activity = self.utils.hourly_arrival_by_qtr_probs_df 130 hourly_activity_for_qtr = hourly_activity[hourly_activity['quarter'] == quarter][['hour','proportion']] 131 132 calls_in_hours = [] 133 134 for i in range(0, self.calls_today): 135 hour = pd.Series.sample(hourly_activity_for_qtr['hour'], weights = hourly_activity_for_qtr['proportion'], 136 random_state=self.utils.rngs["calls_per_hour"]).iloc[0] 137 #self.debug(f"Chosen hour is {hour}") 138 calls_in_hours.append(hour) 139 140 calls_in_hours.sort() 141 142 #self.debug(calls_in_hours) 143 144 d = {} 145 146 hours, counts = np.unique(calls_in_hours, return_counts=True) 147 148 for i in range(len(hours)): 149 d[hours[i]] = counts[i] 150 151 return d 152 153 def predetermine_call_arrival(self, current_hour: int, quarter: int) -> list: 154 """ 155 Function to determine the number of calls in 156 24 hours and the inter-arrival rate for calls in that period 157 Returns a list of times that should be used in the yield timeout statement 158 in a patient generator 159 160 """ 161 162 hourly_activity = self.utils.hourly_arrival_by_qtr_probs_df 163 hourly_activity_for_qtr = hourly_activity[hourly_activity['quarter'] == quarter][['hour','proportion']] 164 165 d = self.calls_per_hour(quarter) 166 167 ia_time = [] 168 169 for index, row in hourly_activity_for_qtr.iterrows(): 170 hour = row['hour'] 171 if hour >= current_hour and hour in d: 172 count = d[hour] 173 if count > 0: 174 scale = 60 / count # mean of exponential = 1 / rate 175 calc_ia_time = self.utils.rngs["predetermine_call_arrival"].exponential(scale=scale) 176 tmp_ia_time = ((hour - current_hour) * 60) + calc_ia_time 177 current_hour += floor(tmp_ia_time / 60) 178 ia_time.append(tmp_ia_time) 179 180 return ia_time 181 182 183 def generate_calls(self): 184 """ 185 **Call generator** 186 187 Generate calls (and patients) until current time equals sim_duration + warm_up_duration. 188 This method calculates number of calls per day and then distributes them according to distributions determined 189 from historic data 190 191 """ 192 193 while self.env.now < (self.sim_duration + self.warm_up_duration): 194 # Get current day of week and hour of day 195 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 196 197 if(self.new_hour != hod): 198 self.new_hour = hod 199 200 #self.debug("new hour") 201 202 # If it is a new day, need to calculate how many calls 203 # in the next 24 hours 204 if(self.new_day != current_dt.date()): 205 # self.debug("It's a new day") 206 # self.debug(dow) 207 # self.debug(f"{self.new_day} and {current_dt.date}") 208 209 # Now have additional option of determining calls per day by quarter instead of season 210 #self.calls_today = int(self.utils.inc_per_day(qtr, self.daily_calls_by_quarter_or_season) * (self.demand_increase_percent)) 211 212 # Try with actual sampled values instead 213 self.calls_today = int(self.utils.inc_per_day_samples(qtr, self.daily_calls_by_quarter_or_season) * (self.demand_increase_percent)) 214 215 # self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today") 216 #self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today") 217 218 self.new_day = current_dt.date() 219 220 ia_dict = {} 221 ia_dict = self.calls_per_hour(qtr) 222 223 #self.debug(ia_dict) 224 225 # Also run scripts to check HEMS resources to see whether they are starting/finishing service 226 yield self.env.process(self.hems_resources.daily_servicing_check(current_dt, hod, month)) 227 228 229 if self.calls_today > 0: 230 if hod in ia_dict.keys(): 231 minutes_elapsed = 0 232 for i in range(0, ia_dict[hod]): 233 if minutes_elapsed < 59: 234 # Determine remaining time and sample a wait time 235 remaining_minutes = 59 - minutes_elapsed 236 # wait_time = random.randint(0, remaining_minutes) 237 wait_time = int(self.utils.rngs["call_iat"].integers(0, remaining_minutes+1)) 238 239 yield self.env.timeout(wait_time) 240 minutes_elapsed += wait_time 241 242 self.env.process(self.generate_patient(dow, hod, weekday, month, qtr, current_dt)) 243 244 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 245 246 247 next_hr = current_dt.floor('h') + pd.Timedelta('1h') 248 yield self.env.timeout(math.ceil(pd.to_timedelta(next_hr - current_dt).total_seconds() / 60)) 249 250 #self.debug('Out of loop') 251 else: 252 # Skip to tomorrow 253 254 self.debug('Skip to tomorrow') 255 256 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 257 next_day = current_dt.floor('d') + pd.Timedelta(days=1) 258 self.debug("next day is {next_day}") 259 yield self.env.timeout(math.ceil(pd.to_timedelta(next_hr - current_dt).total_seconds() / 60)) 260 261 262 def generate_patient(self, dow, hod, weekday, month, qtr, current_dt): 263 264 self.patient_counter += 1 265 266 # Create a new caller/patient 267 pt = Patient(self.patient_counter) 268 269 # Update patient instance with time-based values so the current time is known 270 pt.day = dow 271 pt.hour = hod 272 pt.weekday = weekday 273 pt.month = month 274 pt.qtr = qtr 275 pt.current_dt = current_dt 276 277 #self.debug(f"Patient {pt.id} incident date: {pt.current_dt}") 278 279 # Update patient instance with age, sex, AMPDS card, whether they are a HEMS' patient and if so, the HEMS result, 280 pt.ampds_card = self.utils.ampds_code_selection(pt.hour) 281 #self.debug(f"AMPDS card is {pt.ampds_card}") 282 pt.age = self.utils.age_sampling(pt.ampds_card, 115) 283 pt.sex = self.utils.sex_selection(pt.ampds_card) 284 hems_cc_or_ec = self.utils.care_category_selection(pt.ampds_card) 285 pt.hems_cc_or_ec = hems_cc_or_ec 286 self.debug(f"{pt.current_dt}: {pt.id} Pt allocated to {pt.hems_cc_or_ec} from AMPDS {pt.ampds_card}") 287 288 pt.pt_outcome = self.utils.pt_outcome_selection(pt.hems_cc_or_ec, int(qtr)) 289 self.debug(f"{pt.current_dt}: {pt.id} Pt allocated to patient outcome: {pt.pt_outcome}") 290 291 self.add_patient_result_row(pt, "arrival", "arrival_departure") 292 293 if self.amb_data: 294 # TODO: We'll need the logic to decide whether it is an ambulance or HEMS case 295 # if ambulance data is being collected too. 296 self.debug("Ambulance case") 297 pt.hems_case = 1 if self.utils.rngs["hems_case"].uniform(0, 1) <= 0.5 else pt.hems_case == 0 298 else: 299 pt.hems_case = 1 300 301 if pt.hems_case == 1: 302 #self.debug(f"Going to callsign_group_selection with hour {pt.hour} and AMPDS {pt.ampds_card}") 303 # pt.hems_pref_callsign_group = self.utils.callsign_group_selection(pt.ampds_card) 304 # pt.hems_pref_callsign_group = self.utils.callsign_group_selection(pt.hems_cc_or_ec) 305 pt.hems_pref_callsign_group = self.utils.callsign_group_selection() 306 #self.debug(f"Callsign is {pt.hems_pref_callsign_group}") 307 308 # Some % of calls have a helicopter benefit 309 # Default to y for all patients 310 helicopter_benefit = 'n' 311 312 # Update for REG patients based on historically observed patterns 313 with open("distribution_data/proportion_jobs_heli_benefit.txt", "r") as file: 314 expected_prop_heli_benefit_jobs_reg = float(file.read().strip()) 315 if pt.hems_cc_or_ec == 'REG': 316 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_reg"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_reg else 'n' 317 318 # Update for CC patients based on historically observed patterns 319 with open("distribution_data/proportion_jobs_heli_benefit_cc.txt", "r") as file: 320 expected_prop_heli_benefit_jobs_cc = float(file.read().strip()) 321 if pt.hems_cc_or_ec == 'CC': 322 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_cc"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_cc else 'n' 323 324 # Update for EC patients based on historically observed patterns 325 with open("distribution_data/proportion_jobs_heli_benefit_ec.txt", "r") as file: 326 expected_prop_heli_benefit_jobs_ec = float(file.read().strip()) 327 if pt.hems_cc_or_ec == 'EC': 328 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_ec"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_ec else 'n' 329 330 # Following conversation with HT 21/5 331 # Also count as having had a helicopter benefit if the patient is conveyed by HEMS 332 # This is consistent with how things are done in the golden codes paper 333 # https://static-content.springer.com/esm/art%3A10.1186%2Fs13049-023-01094-w/MediaObjects/13049_2023_1094_MOESM1_ESM.pdf 334 # while having not always been coded in the historic dataset 335 # (TODO: SR 30/5 I have commented this out for now and we should revisit this at some point - 336 # as we might be slightly overestimating heli benefit patients as a result? These need to be 337 # more closely/cleverly linked or just covered by the historic dataset instead) 338 339 if pt.hems_result == "Patient Conveyed by HEMS": 340 helicopter_benefit = 'y' 341 342 pt.hems_helicopter_benefit = helicopter_benefit 343 self.add_patient_result_row(pt, pt.hems_cc_or_ec, "patient_care_category") 344 self.add_patient_result_row(pt, pt.hems_helicopter_benefit, "patient_helicopter_benefit") 345 346 # pt.hems_pref_vehicle_type = self.utils.vehicle_type_selection(pt.hems_pref_callsign_group) 347 pt.hems_pref_vehicle_type = self.utils.vehicle_type_selection_qtr(pt.hems_pref_callsign_group, int(qtr)) 348 # pt.hems_pref_vehicle_type = 'helicopter' 349 #pt.hems_pref_callsign_group = '70' 350 #pt.hems_helicopter_benefit = 'y' 351 352 self.add_patient_result_row(pt, pt.hems_pref_callsign_group, "resource_preferred_resource_group") 353 self.add_patient_result_row(pt, pt.hems_pref_vehicle_type, "resource_preferred_vehicle_type") 354 355 if (pt.hems_cc_or_ec == 'CC' or pt.hems_cc_or_ec == 'EC') and self.utils.rngs["know_cc_ec_benefit"].uniform(0, 1) <= 0.5: 356 hems_res_list: list[HEMS|None, str, HEMS|None] = yield self.hems_resources.allocate_resource(pt) 357 else: 358 # Separate function to determine HEMS resource based on the preferred callsign group 359 # (which is sampled from historical data), the preferred vehicle type 360 hems_res_list: list[HEMS|None, str, HEMS|None] = yield self.hems_resources.allocate_regular_resource(pt) 361 362 363 self.debug(f"{pt.id} hems_res_list: {hems_res_list}") 364 365 hems_allocation = hems_res_list[0] 366 367 # This will either contain the other resource in a callsign_group and HEMS category (EC/CC) or None 368 hems_group_resource_allocation = hems_res_list[2] 369 370 self.add_patient_result_row(pt, hems_res_list[1], "resource_preferred_outcome") 371 372 if hems_allocation is not None: 373 #self.debug(f"allocated {hems_allocation.callsign}") 374 375 self.env.process(self.patient_journey(hems_allocation, pt, hems_group_resource_allocation)) 376 else: 377 #self.debug(f"{pt.current_dt} No HEMS resource available - non-DAAT land crew sent") 378 self.env.process(self.patient_journey(None, pt, None)) 379 380 381 def patient_journey(self, hems_res: HEMS|None, patient: Patient, secondary_hems_res: HEMS|None): 382 """ 383 Send patient on their journey! 384 """ 385 386 #self.debug(f"Patient journey triggered for {patient.id}") 387 #self.debug(f"patient journey Time: {self.env.now}") 388 # self.debug(hems_res.callsign) 389 390 try: 391 patient_enters_sim = self.env.now 392 393 # Note that 'add_patient_result_row' does its own check for whether it's currently within 394 # the warm-up period, so this does not need to be checked manually when adding result 395 # rows in this section 396 # not_in_warm_up_period = False if self.env.now < self.warm_up_duration else True 397 398 # Add variables for quick determination of whether certain parts of the process should 399 # be included per case 400 hems_case = True if patient.hems_case == 1 else False 401 hems_avail = True if hems_res is not None else False 402 403 if not hems_avail: 404 self.debug(f"Patient {patient.id}: No HEMS available") 405 self.add_patient_result_row(patient, "No HEMS available", "queue") 406 self.add_patient_result_row(patient, "No Resource Available", "resource_request_outcome") 407 408 # if hems_avail 409 else: 410 411 # Record selected vehicle type and callsign group in patient object 412 patient.hems_vehicle_type = hems_res.vehicle_type 413 patient.hems_registration = hems_res.registration 414 patient.callsign = hems_res.callsign 415 416 self.add_patient_result_row(patient, patient.hems_vehicle_type, "selected_vehicle_type") 417 self.add_patient_result_row(patient, patient.hems_callsign_group, "selected_callsign_group") 418 419 #patient.hems_result = self.utils.hems_result_by_callsign_group_and_vehicle_type_selection(patient.hems_callsign_group, patient.hems_vehicle_type) 420 #self.debug(f"{patient.hems_cc_or_ec} and {patient.hems_helicopter_benefit}") 421 #patient.hems_result = self.utils.hems_result_by_care_category_and_helicopter_benefit_selection(patient.hems_cc_or_ec, patient.hems_helicopter_benefit) 422 423 # Determine outcome 424 # If we know a resource has been allocated, we can determine the output from historical patterns 425 if hems_res: 426 patient.hems_result = self.utils.hems_results_by_patient_outcome_and_time_of_day_and_quarter_and_vehicle_type_and_callsign_group_probs( 427 patient.pt_outcome, 428 int(patient.qtr), 429 patient.hems_vehicle_type, 430 patient.hems_callsign_group, 431 int(patient.hour) 432 ) 433 else: 434 # Default to what is recorded when no resource sent 435 patient.hems_result = "Unknown" 436 437 self.debug(f"{patient.current_dt}: PT_ID:{patient.id} Pt allocated to HEMS result: {patient.hems_result}") 438 self.add_patient_result_row(patient, "HEMS Resource Available", "resource_request_outcome") 439 self.add_patient_result_row(patient, hems_res.callsign, "resource_use") 440 self.debug(f"{patient.current_dt} Patient {patient.id} (preferred callsign group {patient.hems_pref_callsign_group}, preferred resource type {patient.hems_pref_vehicle_type}) sent resource {hems_res.callsign}") 441 self.add_patient_result_row(patient, hems_res.callsign, "callsign_group_resource_use") 442 443 # Check if HEMS result indicates that resource stood down before going mobile or en route 444 no_HEMS_at_scene = True if patient.hems_result in ["Stand Down"] else False 445 # Check if HEMS result indicates no leaving scene/at hospital times 446 no_HEMS_hospital = ( 447 True if patient.hems_result 448 in ["Stand Down", "Landed but no patient contact", "Patient Treated but not conveyed by HEMS"] 449 else False 450 ) 451 452 #self.debug('Inside hems_avail') 453 if self.amb_data: 454 ambulance = Ambulance() 455 456 patient.time_in_sim = self.env.now - patient_enters_sim 457 458 if hems_case and hems_avail: 459 #self.debug(f"Adding result row with csg {patient.hems_callsign_group}") 460 self.add_patient_result_row(patient, "HEMS call start", "queue") 461 462 if self.amb_data: 463 self.add_patient_result_row(patient, "AMB call start", "queue") 464 465 # Allocation time -------------- 466 # Allocation will always take place if a resource is found 467 468 if hems_case and hems_avail: 469 # Calculate min and max permitted times. 470 allocation_time = ( 471 self.utils.activity_time(patient.hems_vehicle_type, 'time_allocation') 472 * self.activity_duration_multiplier 473 ) 474 self.add_patient_result_row(patient, allocation_time, 'time_allocation') 475 self.add_patient_result_row(patient, "HEMS allocated to call", "queue") 476 #self.debug(f"Vehicle type {patient.hems_vehicle_type} and allocation time is {allocation_time}") 477 yield self.env.timeout(allocation_time) 478 479 if self.amb_data: 480 #self.debug('Ambulance allocation time') 481 yield self.env.timeout(180) 482 483 patient.time_in_sim = self.env.now - patient_enters_sim 484 485 # Mobilisation time --------------- 486 487 # Calculate mobile to time at scene (or stood down before) 488 if hems_case and hems_avail: 489 mobile_time = ( 490 self.utils.activity_time(patient.hems_vehicle_type, 'time_mobile') 491 * self.activity_duration_multiplier 492 ) 493 self.add_patient_result_row(patient, mobile_time, 'time_mobile') 494 self.add_patient_result_row(patient, "HEMS mobile", "queue") 495 yield self.env.timeout(mobile_time) 496 497 if self.amb_data: 498 # Determine allocation time for ambulance 499 # Yield until done 500 #self.debug('Ambulance time to going mobile') 501 self.debug('Ambulance mobile') 502 yield self.env.timeout(1) 503 504 patient.time_in_sim = self.env.now - patient_enters_sim 505 506 # On scene --------------- 507 508 if hems_case and hems_avail and not no_HEMS_at_scene: 509 tts_time = ( 510 self.utils.activity_time(patient.hems_vehicle_type, 'time_to_scene') 511 * self.activity_duration_multiplier 512 ) 513 self.add_patient_result_row(patient, tts_time, "time_to_scene") 514 self.add_patient_result_row(patient, "HEMS on scene", "queue") 515 yield self.env.timeout(tts_time) 516 517 if self.amb_data: 518 # Determine allocation time for ambulance 519 # Yield until done 520 #self.debug('Ambulance time to scene') 521 yield self.env.timeout(20) 522 523 patient.time_in_sim = self.env.now - patient_enters_sim 524 525 if self.amb_data: 526 self.debug('Ambulance stand down en route') 527 528 529 # Leaving scene ------------ 530 531 if hems_case and hems_avail and not no_HEMS_at_scene: 532 tos_time = ( 533 self.utils.activity_time(patient.hems_vehicle_type, 'time_on_scene') 534 * self.activity_duration_multiplier 535 ) 536 self.add_patient_result_row(patient, tos_time, "time_on_scene") 537 yield self.env.timeout(tos_time) 538 539 if no_HEMS_hospital: 540 self.add_patient_result_row(patient, f"HEMS {patient.hems_result.lower()}", "queue") 541 else: 542 self.add_patient_result_row(patient, "HEMS leaving scene", "queue") 543 544 if self.amb_data: 545 #self.debug('Ambulance on scene duration') 546 yield self.env.timeout(120) 547 self.debug('Ambulance leaving scene time') 548 549 patient.time_in_sim = self.env.now - patient_enters_sim 550 551 # Arrived destination time ------------ 552 553 if hems_case and hems_avail and not no_HEMS_hospital: 554 travel_time = self.utils.activity_time(patient.hems_vehicle_type, 'time_to_hospital') * self.activity_duration_multiplier 555 self.add_patient_result_row(patient, travel_time, 'time_to_hospital') 556 self.add_patient_result_row(patient, "HEMS arrived destination", "queue") 557 yield self.env.timeout(travel_time) 558 559 if self.amb_data: 560 #self.debug('Ambulance travel time') 561 yield self.env.timeout(30) 562 self.debug('Ambulance at destination time') 563 564 patient.time_in_sim = self.env.now - patient_enters_sim 565 566 # Handover time --------------- 567 568 # Not currently available 569 570 # Clear time ------------ 571 572 if hems_case and hems_avail: 573 clear_time = ( 574 self.utils.activity_time(patient.hems_vehicle_type, 'time_to_clear') 575 * self.activity_duration_multiplier 576 ) 577 self.add_patient_result_row(patient, clear_time, 'time_to_clear') 578 self.add_patient_result_row(patient, "HEMS clear", "queue") 579 yield self.env.timeout(clear_time) 580 581 if self.amb_data: 582 #self.debug('Ambulance clear time') 583 yield self.env.timeout(60) 584 585 patient.time_in_sim = self.env.now - patient_enters_sim 586 587 if self.amb_data: 588 self.debug('Ambulance clear time') 589 590 #self.debug(f"Depart for patient {patient.id} on run {self.run_number}") 591 592 self.add_patient_result_row(patient, "depart", "arrival_departure") 593 finally: 594 # Always return the resource at the end of the patient journey. 595 if hems_res is not None: 596 self.hems_resources.return_resource(hems_res, secondary_hems_res) 597 self.debug(f"Attempting to return {hems_res} and {secondary_hems_res} to resource store") 598 self.add_patient_result_row(patient, hems_res.callsign, "resource_use_end") 599 600 def add_patient_result_row(self, 601 patient: Patient, 602 time_type: str, 603 event_type: str, 604 **kwargs) -> None : 605 """ 606 Convenience function to create a row of data for the results table 607 608 """ 609 610 results = { 611 "P_ID" : patient.id, 612 "run_number" : self.run_number, 613 "time_type" : time_type, # e.g. mobile, at scene, leaving scene etc. 614 "event_type" : event_type, # for animation: arrival_departure, queue, resource_use, resource_use_end 615 "timestamp" : self.env.now, 616 "timestamp_dt" : self.sim_start_date + timedelta(minutes=self.env.now), 617 "day" : patient.day, 618 "hour" : patient.hour, 619 "weekday" : patient.weekday, 620 "month" : patient.month, 621 "qtr" : patient.qtr, 622 "registration" : patient.hems_registration, # registration of allocated/attending vehicle 623 "callsign" : patient.callsign, # callsign of allocated/attending vehicle 624 "callsign_group" : patient.hems_callsign_group, # callsign group of allocated/attending vehicle 625 "vehicle_type" : patient.hems_vehicle_type, # vehicle type (car/helicopter) of allocated/attending vehicle 626 "hems_res_category" : patient.hems_category, 627 "ampds_card" : patient.ampds_card, 628 "age" : patient.age, 629 "sex" : patient.sex, 630 "care_cat" : patient.hems_cc_or_ec, 631 "heli_benefit" : patient.hems_helicopter_benefit, 632 "hems_result" : patient.hems_result, 633 "outcome" : patient.pt_outcome, 634 "hems_reg" : patient.hems_registration 635 } 636 637 #self.debug(results) 638 639 # Add any additional items passed in **kwargs 640 for key, value in kwargs.items(): 641 results[key] = value 642 643 if self.env.now >= self.warm_up_duration: 644 self.store_patient_results(results) 645 646 647 def store_patient_results(self, results: dict) -> None: 648 """ 649 Adds a row of data to the Class' `result_df` dataframe 650 """ 651 652 self.results_list.append(results) 653 654 655 def convert_results_to_df(self, results: dict) -> None: 656 self.results_df = pd.DataFrame(results) 657 try: 658 self.results_df.set_index("P_ID", inplace=True) 659 # TODO - Improve error handling here 660 except KeyError: 661 pass 662 663 def write_all_results(self) -> None: 664 """ 665 Writes the content of `result_df` to a csv file 666 """ 667 # https://stackoverflow.com/a/30991707/3650230 668 669 # Check if file exists...if it does, append data, otherwise create a new file with headers matching 670 # the column names of `results_df` 671 if not os.path.isfile(self.all_results_location): 672 self.results_df.to_csv(self.all_results_location, header='column_names') 673 else: # else it exists so append without writing the header 674 self.results_df.to_csv(self.all_results_location, mode='a', header=False) 675 676 def write_run_results(self) -> None: 677 """ 678 Writes the content of `result_dfs` to csv files that contains only the results from the 679 single run 680 681 Note that this cannot be done in a similar manner to write_all_results due to the impacts of 682 the parallisation approach that is taken with joblib - depending on process timings it can lead to 683 not all 684 """ 685 686 self.results_df.to_csv(f"{Utils.RESULTS_FOLDER}/output_run_{self.run_number}.csv", header='column_names') 687 688 def run(self) -> None: 689 """ 690 Function to start the simulation. 691 692 """ 693 self.debug(f"HEMS class initialised with the following: run {self.run_number}, duration {self.sim_duration}, warm-up {self.warm_up_duration}, start date {self.sim_start_date}, demand increase multiplier {self.demand_increase_percent}, activity duration multiplier {self.activity_duration_multiplier}") 694 695 # Start entity generators 696 self.env.process(self.generate_calls()) 697 698 # Run simulation 699 self.env.run(until=(self.sim_duration + self.warm_up_duration)) 700 701 # Convert results to a dataframe 702 self.convert_results_to_df(results=self.results_list) 703 704 # Write run results to file 705 self.write_all_results() 706 self.write_run_results()
The DES_HEMS class
This class contains everything require to undertake a single DES run for multiple patients over the specified duration.
NOTE: The unit of sim is minutes
40 def __init__(self, 41 run_number: int, 42 sim_duration: int, 43 warm_up_duration: int, 44 sim_start_date: str, 45 amb_data: bool, 46 random_seed: int, 47 demand_increase_percent: float, 48 activity_duration_multiplier: float, 49 print_debug_messages: bool): 50 51 self.run_number = run_number + 1 # Add 1 so we don't have a run_number 0 52 self.sim_duration = sim_duration 53 self.warm_up_duration = warm_up_duration 54 self.sim_start_date = sim_start_date 55 56 self.random_seed = random_seed 57 self.random_seed_sequence = SeedSequence(self.random_seed) 58 59 self.print_debug_messages = print_debug_messages 60 61 self.utils = Utils( 62 master_seed=self.random_seed_sequence.spawn(1)[0], 63 print_debug_messages=self.print_debug_messages 64 ) 65 66 self.utils.setup_seeds() 67 68 self.demand_increase_percent = demand_increase_percent 69 70 # Option for sampling calls per day by season or quarter 71 self.daily_calls_by_quarter_or_season = 'quarter' 72 73 # Option to include/exclude ambulance service cases in addition to HEMS 74 self.amb_data = amb_data 75 #self.debug(f"Ambulance data values is {self.amb_data}") 76 77 self.all_results_location = self.utils.ALL_RESULTS_CSV 78 self.run_results_location = self.utils.RUN_RESULTS_CSV 79 80 self.env = simpy.Environment() 81 self.patient_counter = 0 82 self.calls_today = 0 83 self.new_day = pd.to_datetime("1900-01-01").date 84 self.new_hour = -1 85 86 self.hems_resources = HEMSAvailability(env=self.env, 87 sim_start_date=sim_start_date, 88 sim_duration=sim_duration, 89 print_debug_messages=self.print_debug_messages, 90 master_seed=self.random_seed_sequence, 91 utility=self.utils 92 ) 93 94 # Set up empty list to store results prior to conversion to dataframe 95 self.results_list = [] 96 97 # Set up data frame to capture time points etc. during the simulation 98 # We might not need all of these, but simpler to capture them all for now. 99 100 # This might be better as a separate 'data collection' class of some sort. 101 # I am thinking that each resource will need its own entry for any given 102 # patient to account for dual response (ambulance service and HEMS) 103 # stand downs etc. 104 self.results_df = None 105 106 # self.inter_arrival_times_df = pd.read_csv('distribution_data/inter_arrival_times.csv') 107 108 self.activity_duration_multiplier = activity_duration_multiplier 109 110 # self.seeded_dists = self.utils.build_seeded_distributions( 111 # self.utils.activity_time_distr, 112 # master_seed=self.random_seed 113 # )
121 def calls_per_hour(self, quarter: int) -> dict: 122 """ 123 Function to return a dictionary of keys representing the hour of day 124 and value representing the number of calls in that hour 125 """ 126 127 # self.debug(f"There are going to be {self.calls_today} calls today and the current hour is {current_hour}") 128 129 hourly_activity = self.utils.hourly_arrival_by_qtr_probs_df 130 hourly_activity_for_qtr = hourly_activity[hourly_activity['quarter'] == quarter][['hour','proportion']] 131 132 calls_in_hours = [] 133 134 for i in range(0, self.calls_today): 135 hour = pd.Series.sample(hourly_activity_for_qtr['hour'], weights = hourly_activity_for_qtr['proportion'], 136 random_state=self.utils.rngs["calls_per_hour"]).iloc[0] 137 #self.debug(f"Chosen hour is {hour}") 138 calls_in_hours.append(hour) 139 140 calls_in_hours.sort() 141 142 #self.debug(calls_in_hours) 143 144 d = {} 145 146 hours, counts = np.unique(calls_in_hours, return_counts=True) 147 148 for i in range(len(hours)): 149 d[hours[i]] = counts[i] 150 151 return d
Function to return a dictionary of keys representing the hour of day and value representing the number of calls in that hour
153 def predetermine_call_arrival(self, current_hour: int, quarter: int) -> list: 154 """ 155 Function to determine the number of calls in 156 24 hours and the inter-arrival rate for calls in that period 157 Returns a list of times that should be used in the yield timeout statement 158 in a patient generator 159 160 """ 161 162 hourly_activity = self.utils.hourly_arrival_by_qtr_probs_df 163 hourly_activity_for_qtr = hourly_activity[hourly_activity['quarter'] == quarter][['hour','proportion']] 164 165 d = self.calls_per_hour(quarter) 166 167 ia_time = [] 168 169 for index, row in hourly_activity_for_qtr.iterrows(): 170 hour = row['hour'] 171 if hour >= current_hour and hour in d: 172 count = d[hour] 173 if count > 0: 174 scale = 60 / count # mean of exponential = 1 / rate 175 calc_ia_time = self.utils.rngs["predetermine_call_arrival"].exponential(scale=scale) 176 tmp_ia_time = ((hour - current_hour) * 60) + calc_ia_time 177 current_hour += floor(tmp_ia_time / 60) 178 ia_time.append(tmp_ia_time) 179 180 return ia_time
Function to determine the number of calls in 24 hours and the inter-arrival rate for calls in that period Returns a list of times that should be used in the yield timeout statement in a patient generator
183 def generate_calls(self): 184 """ 185 **Call generator** 186 187 Generate calls (and patients) until current time equals sim_duration + warm_up_duration. 188 This method calculates number of calls per day and then distributes them according to distributions determined 189 from historic data 190 191 """ 192 193 while self.env.now < (self.sim_duration + self.warm_up_duration): 194 # Get current day of week and hour of day 195 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 196 197 if(self.new_hour != hod): 198 self.new_hour = hod 199 200 #self.debug("new hour") 201 202 # If it is a new day, need to calculate how many calls 203 # in the next 24 hours 204 if(self.new_day != current_dt.date()): 205 # self.debug("It's a new day") 206 # self.debug(dow) 207 # self.debug(f"{self.new_day} and {current_dt.date}") 208 209 # Now have additional option of determining calls per day by quarter instead of season 210 #self.calls_today = int(self.utils.inc_per_day(qtr, self.daily_calls_by_quarter_or_season) * (self.demand_increase_percent)) 211 212 # Try with actual sampled values instead 213 self.calls_today = int(self.utils.inc_per_day_samples(qtr, self.daily_calls_by_quarter_or_season) * (self.demand_increase_percent)) 214 215 # self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today") 216 #self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today") 217 218 self.new_day = current_dt.date() 219 220 ia_dict = {} 221 ia_dict = self.calls_per_hour(qtr) 222 223 #self.debug(ia_dict) 224 225 # Also run scripts to check HEMS resources to see whether they are starting/finishing service 226 yield self.env.process(self.hems_resources.daily_servicing_check(current_dt, hod, month)) 227 228 229 if self.calls_today > 0: 230 if hod in ia_dict.keys(): 231 minutes_elapsed = 0 232 for i in range(0, ia_dict[hod]): 233 if minutes_elapsed < 59: 234 # Determine remaining time and sample a wait time 235 remaining_minutes = 59 - minutes_elapsed 236 # wait_time = random.randint(0, remaining_minutes) 237 wait_time = int(self.utils.rngs["call_iat"].integers(0, remaining_minutes+1)) 238 239 yield self.env.timeout(wait_time) 240 minutes_elapsed += wait_time 241 242 self.env.process(self.generate_patient(dow, hod, weekday, month, qtr, current_dt)) 243 244 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 245 246 247 next_hr = current_dt.floor('h') + pd.Timedelta('1h') 248 yield self.env.timeout(math.ceil(pd.to_timedelta(next_hr - current_dt).total_seconds() / 60)) 249 250 #self.debug('Out of loop') 251 else: 252 # Skip to tomorrow 253 254 self.debug('Skip to tomorrow') 255 256 [dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(self.sim_start_date, self.env.now) 257 next_day = current_dt.floor('d') + pd.Timedelta(days=1) 258 self.debug("next day is {next_day}") 259 yield self.env.timeout(math.ceil(pd.to_timedelta(next_hr - current_dt).total_seconds() / 60))
Call generator
Generate calls (and patients) until current time equals sim_duration + warm_up_duration. This method calculates number of calls per day and then distributes them according to distributions determined from historic data
262 def generate_patient(self, dow, hod, weekday, month, qtr, current_dt): 263 264 self.patient_counter += 1 265 266 # Create a new caller/patient 267 pt = Patient(self.patient_counter) 268 269 # Update patient instance with time-based values so the current time is known 270 pt.day = dow 271 pt.hour = hod 272 pt.weekday = weekday 273 pt.month = month 274 pt.qtr = qtr 275 pt.current_dt = current_dt 276 277 #self.debug(f"Patient {pt.id} incident date: {pt.current_dt}") 278 279 # Update patient instance with age, sex, AMPDS card, whether they are a HEMS' patient and if so, the HEMS result, 280 pt.ampds_card = self.utils.ampds_code_selection(pt.hour) 281 #self.debug(f"AMPDS card is {pt.ampds_card}") 282 pt.age = self.utils.age_sampling(pt.ampds_card, 115) 283 pt.sex = self.utils.sex_selection(pt.ampds_card) 284 hems_cc_or_ec = self.utils.care_category_selection(pt.ampds_card) 285 pt.hems_cc_or_ec = hems_cc_or_ec 286 self.debug(f"{pt.current_dt}: {pt.id} Pt allocated to {pt.hems_cc_or_ec} from AMPDS {pt.ampds_card}") 287 288 pt.pt_outcome = self.utils.pt_outcome_selection(pt.hems_cc_or_ec, int(qtr)) 289 self.debug(f"{pt.current_dt}: {pt.id} Pt allocated to patient outcome: {pt.pt_outcome}") 290 291 self.add_patient_result_row(pt, "arrival", "arrival_departure") 292 293 if self.amb_data: 294 # TODO: We'll need the logic to decide whether it is an ambulance or HEMS case 295 # if ambulance data is being collected too. 296 self.debug("Ambulance case") 297 pt.hems_case = 1 if self.utils.rngs["hems_case"].uniform(0, 1) <= 0.5 else pt.hems_case == 0 298 else: 299 pt.hems_case = 1 300 301 if pt.hems_case == 1: 302 #self.debug(f"Going to callsign_group_selection with hour {pt.hour} and AMPDS {pt.ampds_card}") 303 # pt.hems_pref_callsign_group = self.utils.callsign_group_selection(pt.ampds_card) 304 # pt.hems_pref_callsign_group = self.utils.callsign_group_selection(pt.hems_cc_or_ec) 305 pt.hems_pref_callsign_group = self.utils.callsign_group_selection() 306 #self.debug(f"Callsign is {pt.hems_pref_callsign_group}") 307 308 # Some % of calls have a helicopter benefit 309 # Default to y for all patients 310 helicopter_benefit = 'n' 311 312 # Update for REG patients based on historically observed patterns 313 with open("distribution_data/proportion_jobs_heli_benefit.txt", "r") as file: 314 expected_prop_heli_benefit_jobs_reg = float(file.read().strip()) 315 if pt.hems_cc_or_ec == 'REG': 316 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_reg"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_reg else 'n' 317 318 # Update for CC patients based on historically observed patterns 319 with open("distribution_data/proportion_jobs_heli_benefit_cc.txt", "r") as file: 320 expected_prop_heli_benefit_jobs_cc = float(file.read().strip()) 321 if pt.hems_cc_or_ec == 'CC': 322 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_cc"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_cc else 'n' 323 324 # Update for EC patients based on historically observed patterns 325 with open("distribution_data/proportion_jobs_heli_benefit_ec.txt", "r") as file: 326 expected_prop_heli_benefit_jobs_ec = float(file.read().strip()) 327 if pt.hems_cc_or_ec == 'EC': 328 helicopter_benefit = 'y' if self.utils.rngs["helicopter_benefit_from_ec"].uniform(0, 1) <= expected_prop_heli_benefit_jobs_ec else 'n' 329 330 # Following conversation with HT 21/5 331 # Also count as having had a helicopter benefit if the patient is conveyed by HEMS 332 # This is consistent with how things are done in the golden codes paper 333 # https://static-content.springer.com/esm/art%3A10.1186%2Fs13049-023-01094-w/MediaObjects/13049_2023_1094_MOESM1_ESM.pdf 334 # while having not always been coded in the historic dataset 335 # (TODO: SR 30/5 I have commented this out for now and we should revisit this at some point - 336 # as we might be slightly overestimating heli benefit patients as a result? These need to be 337 # more closely/cleverly linked or just covered by the historic dataset instead) 338 339 if pt.hems_result == "Patient Conveyed by HEMS": 340 helicopter_benefit = 'y' 341 342 pt.hems_helicopter_benefit = helicopter_benefit 343 self.add_patient_result_row(pt, pt.hems_cc_or_ec, "patient_care_category") 344 self.add_patient_result_row(pt, pt.hems_helicopter_benefit, "patient_helicopter_benefit") 345 346 # pt.hems_pref_vehicle_type = self.utils.vehicle_type_selection(pt.hems_pref_callsign_group) 347 pt.hems_pref_vehicle_type = self.utils.vehicle_type_selection_qtr(pt.hems_pref_callsign_group, int(qtr)) 348 # pt.hems_pref_vehicle_type = 'helicopter' 349 #pt.hems_pref_callsign_group = '70' 350 #pt.hems_helicopter_benefit = 'y' 351 352 self.add_patient_result_row(pt, pt.hems_pref_callsign_group, "resource_preferred_resource_group") 353 self.add_patient_result_row(pt, pt.hems_pref_vehicle_type, "resource_preferred_vehicle_type") 354 355 if (pt.hems_cc_or_ec == 'CC' or pt.hems_cc_or_ec == 'EC') and self.utils.rngs["know_cc_ec_benefit"].uniform(0, 1) <= 0.5: 356 hems_res_list: list[HEMS|None, str, HEMS|None] = yield self.hems_resources.allocate_resource(pt) 357 else: 358 # Separate function to determine HEMS resource based on the preferred callsign group 359 # (which is sampled from historical data), the preferred vehicle type 360 hems_res_list: list[HEMS|None, str, HEMS|None] = yield self.hems_resources.allocate_regular_resource(pt) 361 362 363 self.debug(f"{pt.id} hems_res_list: {hems_res_list}") 364 365 hems_allocation = hems_res_list[0] 366 367 # This will either contain the other resource in a callsign_group and HEMS category (EC/CC) or None 368 hems_group_resource_allocation = hems_res_list[2] 369 370 self.add_patient_result_row(pt, hems_res_list[1], "resource_preferred_outcome") 371 372 if hems_allocation is not None: 373 #self.debug(f"allocated {hems_allocation.callsign}") 374 375 self.env.process(self.patient_journey(hems_allocation, pt, hems_group_resource_allocation)) 376 else: 377 #self.debug(f"{pt.current_dt} No HEMS resource available - non-DAAT land crew sent") 378 self.env.process(self.patient_journey(None, pt, None))
381 def patient_journey(self, hems_res: HEMS|None, patient: Patient, secondary_hems_res: HEMS|None): 382 """ 383 Send patient on their journey! 384 """ 385 386 #self.debug(f"Patient journey triggered for {patient.id}") 387 #self.debug(f"patient journey Time: {self.env.now}") 388 # self.debug(hems_res.callsign) 389 390 try: 391 patient_enters_sim = self.env.now 392 393 # Note that 'add_patient_result_row' does its own check for whether it's currently within 394 # the warm-up period, so this does not need to be checked manually when adding result 395 # rows in this section 396 # not_in_warm_up_period = False if self.env.now < self.warm_up_duration else True 397 398 # Add variables for quick determination of whether certain parts of the process should 399 # be included per case 400 hems_case = True if patient.hems_case == 1 else False 401 hems_avail = True if hems_res is not None else False 402 403 if not hems_avail: 404 self.debug(f"Patient {patient.id}: No HEMS available") 405 self.add_patient_result_row(patient, "No HEMS available", "queue") 406 self.add_patient_result_row(patient, "No Resource Available", "resource_request_outcome") 407 408 # if hems_avail 409 else: 410 411 # Record selected vehicle type and callsign group in patient object 412 patient.hems_vehicle_type = hems_res.vehicle_type 413 patient.hems_registration = hems_res.registration 414 patient.callsign = hems_res.callsign 415 416 self.add_patient_result_row(patient, patient.hems_vehicle_type, "selected_vehicle_type") 417 self.add_patient_result_row(patient, patient.hems_callsign_group, "selected_callsign_group") 418 419 #patient.hems_result = self.utils.hems_result_by_callsign_group_and_vehicle_type_selection(patient.hems_callsign_group, patient.hems_vehicle_type) 420 #self.debug(f"{patient.hems_cc_or_ec} and {patient.hems_helicopter_benefit}") 421 #patient.hems_result = self.utils.hems_result_by_care_category_and_helicopter_benefit_selection(patient.hems_cc_or_ec, patient.hems_helicopter_benefit) 422 423 # Determine outcome 424 # If we know a resource has been allocated, we can determine the output from historical patterns 425 if hems_res: 426 patient.hems_result = self.utils.hems_results_by_patient_outcome_and_time_of_day_and_quarter_and_vehicle_type_and_callsign_group_probs( 427 patient.pt_outcome, 428 int(patient.qtr), 429 patient.hems_vehicle_type, 430 patient.hems_callsign_group, 431 int(patient.hour) 432 ) 433 else: 434 # Default to what is recorded when no resource sent 435 patient.hems_result = "Unknown" 436 437 self.debug(f"{patient.current_dt}: PT_ID:{patient.id} Pt allocated to HEMS result: {patient.hems_result}") 438 self.add_patient_result_row(patient, "HEMS Resource Available", "resource_request_outcome") 439 self.add_patient_result_row(patient, hems_res.callsign, "resource_use") 440 self.debug(f"{patient.current_dt} Patient {patient.id} (preferred callsign group {patient.hems_pref_callsign_group}, preferred resource type {patient.hems_pref_vehicle_type}) sent resource {hems_res.callsign}") 441 self.add_patient_result_row(patient, hems_res.callsign, "callsign_group_resource_use") 442 443 # Check if HEMS result indicates that resource stood down before going mobile or en route 444 no_HEMS_at_scene = True if patient.hems_result in ["Stand Down"] else False 445 # Check if HEMS result indicates no leaving scene/at hospital times 446 no_HEMS_hospital = ( 447 True if patient.hems_result 448 in ["Stand Down", "Landed but no patient contact", "Patient Treated but not conveyed by HEMS"] 449 else False 450 ) 451 452 #self.debug('Inside hems_avail') 453 if self.amb_data: 454 ambulance = Ambulance() 455 456 patient.time_in_sim = self.env.now - patient_enters_sim 457 458 if hems_case and hems_avail: 459 #self.debug(f"Adding result row with csg {patient.hems_callsign_group}") 460 self.add_patient_result_row(patient, "HEMS call start", "queue") 461 462 if self.amb_data: 463 self.add_patient_result_row(patient, "AMB call start", "queue") 464 465 # Allocation time -------------- 466 # Allocation will always take place if a resource is found 467 468 if hems_case and hems_avail: 469 # Calculate min and max permitted times. 470 allocation_time = ( 471 self.utils.activity_time(patient.hems_vehicle_type, 'time_allocation') 472 * self.activity_duration_multiplier 473 ) 474 self.add_patient_result_row(patient, allocation_time, 'time_allocation') 475 self.add_patient_result_row(patient, "HEMS allocated to call", "queue") 476 #self.debug(f"Vehicle type {patient.hems_vehicle_type} and allocation time is {allocation_time}") 477 yield self.env.timeout(allocation_time) 478 479 if self.amb_data: 480 #self.debug('Ambulance allocation time') 481 yield self.env.timeout(180) 482 483 patient.time_in_sim = self.env.now - patient_enters_sim 484 485 # Mobilisation time --------------- 486 487 # Calculate mobile to time at scene (or stood down before) 488 if hems_case and hems_avail: 489 mobile_time = ( 490 self.utils.activity_time(patient.hems_vehicle_type, 'time_mobile') 491 * self.activity_duration_multiplier 492 ) 493 self.add_patient_result_row(patient, mobile_time, 'time_mobile') 494 self.add_patient_result_row(patient, "HEMS mobile", "queue") 495 yield self.env.timeout(mobile_time) 496 497 if self.amb_data: 498 # Determine allocation time for ambulance 499 # Yield until done 500 #self.debug('Ambulance time to going mobile') 501 self.debug('Ambulance mobile') 502 yield self.env.timeout(1) 503 504 patient.time_in_sim = self.env.now - patient_enters_sim 505 506 # On scene --------------- 507 508 if hems_case and hems_avail and not no_HEMS_at_scene: 509 tts_time = ( 510 self.utils.activity_time(patient.hems_vehicle_type, 'time_to_scene') 511 * self.activity_duration_multiplier 512 ) 513 self.add_patient_result_row(patient, tts_time, "time_to_scene") 514 self.add_patient_result_row(patient, "HEMS on scene", "queue") 515 yield self.env.timeout(tts_time) 516 517 if self.amb_data: 518 # Determine allocation time for ambulance 519 # Yield until done 520 #self.debug('Ambulance time to scene') 521 yield self.env.timeout(20) 522 523 patient.time_in_sim = self.env.now - patient_enters_sim 524 525 if self.amb_data: 526 self.debug('Ambulance stand down en route') 527 528 529 # Leaving scene ------------ 530 531 if hems_case and hems_avail and not no_HEMS_at_scene: 532 tos_time = ( 533 self.utils.activity_time(patient.hems_vehicle_type, 'time_on_scene') 534 * self.activity_duration_multiplier 535 ) 536 self.add_patient_result_row(patient, tos_time, "time_on_scene") 537 yield self.env.timeout(tos_time) 538 539 if no_HEMS_hospital: 540 self.add_patient_result_row(patient, f"HEMS {patient.hems_result.lower()}", "queue") 541 else: 542 self.add_patient_result_row(patient, "HEMS leaving scene", "queue") 543 544 if self.amb_data: 545 #self.debug('Ambulance on scene duration') 546 yield self.env.timeout(120) 547 self.debug('Ambulance leaving scene time') 548 549 patient.time_in_sim = self.env.now - patient_enters_sim 550 551 # Arrived destination time ------------ 552 553 if hems_case and hems_avail and not no_HEMS_hospital: 554 travel_time = self.utils.activity_time(patient.hems_vehicle_type, 'time_to_hospital') * self.activity_duration_multiplier 555 self.add_patient_result_row(patient, travel_time, 'time_to_hospital') 556 self.add_patient_result_row(patient, "HEMS arrived destination", "queue") 557 yield self.env.timeout(travel_time) 558 559 if self.amb_data: 560 #self.debug('Ambulance travel time') 561 yield self.env.timeout(30) 562 self.debug('Ambulance at destination time') 563 564 patient.time_in_sim = self.env.now - patient_enters_sim 565 566 # Handover time --------------- 567 568 # Not currently available 569 570 # Clear time ------------ 571 572 if hems_case and hems_avail: 573 clear_time = ( 574 self.utils.activity_time(patient.hems_vehicle_type, 'time_to_clear') 575 * self.activity_duration_multiplier 576 ) 577 self.add_patient_result_row(patient, clear_time, 'time_to_clear') 578 self.add_patient_result_row(patient, "HEMS clear", "queue") 579 yield self.env.timeout(clear_time) 580 581 if self.amb_data: 582 #self.debug('Ambulance clear time') 583 yield self.env.timeout(60) 584 585 patient.time_in_sim = self.env.now - patient_enters_sim 586 587 if self.amb_data: 588 self.debug('Ambulance clear time') 589 590 #self.debug(f"Depart for patient {patient.id} on run {self.run_number}") 591 592 self.add_patient_result_row(patient, "depart", "arrival_departure") 593 finally: 594 # Always return the resource at the end of the patient journey. 595 if hems_res is not None: 596 self.hems_resources.return_resource(hems_res, secondary_hems_res) 597 self.debug(f"Attempting to return {hems_res} and {secondary_hems_res} to resource store") 598 self.add_patient_result_row(patient, hems_res.callsign, "resource_use_end")
Send patient on their journey!
600 def add_patient_result_row(self, 601 patient: Patient, 602 time_type: str, 603 event_type: str, 604 **kwargs) -> None : 605 """ 606 Convenience function to create a row of data for the results table 607 608 """ 609 610 results = { 611 "P_ID" : patient.id, 612 "run_number" : self.run_number, 613 "time_type" : time_type, # e.g. mobile, at scene, leaving scene etc. 614 "event_type" : event_type, # for animation: arrival_departure, queue, resource_use, resource_use_end 615 "timestamp" : self.env.now, 616 "timestamp_dt" : self.sim_start_date + timedelta(minutes=self.env.now), 617 "day" : patient.day, 618 "hour" : patient.hour, 619 "weekday" : patient.weekday, 620 "month" : patient.month, 621 "qtr" : patient.qtr, 622 "registration" : patient.hems_registration, # registration of allocated/attending vehicle 623 "callsign" : patient.callsign, # callsign of allocated/attending vehicle 624 "callsign_group" : patient.hems_callsign_group, # callsign group of allocated/attending vehicle 625 "vehicle_type" : patient.hems_vehicle_type, # vehicle type (car/helicopter) of allocated/attending vehicle 626 "hems_res_category" : patient.hems_category, 627 "ampds_card" : patient.ampds_card, 628 "age" : patient.age, 629 "sex" : patient.sex, 630 "care_cat" : patient.hems_cc_or_ec, 631 "heli_benefit" : patient.hems_helicopter_benefit, 632 "hems_result" : patient.hems_result, 633 "outcome" : patient.pt_outcome, 634 "hems_reg" : patient.hems_registration 635 } 636 637 #self.debug(results) 638 639 # Add any additional items passed in **kwargs 640 for key, value in kwargs.items(): 641 results[key] = value 642 643 if self.env.now >= self.warm_up_duration: 644 self.store_patient_results(results)
Convenience function to create a row of data for the results table
647 def store_patient_results(self, results: dict) -> None: 648 """ 649 Adds a row of data to the Class' `result_df` dataframe 650 """ 651 652 self.results_list.append(results)
Adds a row of data to the Class' result_df
dataframe
663 def write_all_results(self) -> None: 664 """ 665 Writes the content of `result_df` to a csv file 666 """ 667 # https://stackoverflow.com/a/30991707/3650230 668 669 # Check if file exists...if it does, append data, otherwise create a new file with headers matching 670 # the column names of `results_df` 671 if not os.path.isfile(self.all_results_location): 672 self.results_df.to_csv(self.all_results_location, header='column_names') 673 else: # else it exists so append without writing the header 674 self.results_df.to_csv(self.all_results_location, mode='a', header=False)
Writes the content of result_df
to a csv file
676 def write_run_results(self) -> None: 677 """ 678 Writes the content of `result_dfs` to csv files that contains only the results from the 679 single run 680 681 Note that this cannot be done in a similar manner to write_all_results due to the impacts of 682 the parallisation approach that is taken with joblib - depending on process timings it can lead to 683 not all 684 """ 685 686 self.results_df.to_csv(f"{Utils.RESULTS_FOLDER}/output_run_{self.run_number}.csv", header='column_names')
Writes the content of result_dfs
to csv files that contains only the results from the
single run
Note that this cannot be done in a similar manner to write_all_results due to the impacts of the parallisation approach that is taken with joblib - depending on process timings it can lead to not all
688 def run(self) -> None: 689 """ 690 Function to start the simulation. 691 692 """ 693 self.debug(f"HEMS class initialised with the following: run {self.run_number}, duration {self.sim_duration}, warm-up {self.warm_up_duration}, start date {self.sim_start_date}, demand increase multiplier {self.demand_increase_percent}, activity duration multiplier {self.activity_duration_multiplier}") 694 695 # Start entity generators 696 self.env.process(self.generate_calls()) 697 698 # Run simulation 699 self.env.run(until=(self.sim_duration + self.warm_up_duration)) 700 701 # Convert results to a dataframe 702 self.convert_results_to_df(results=self.results_list) 703 704 # Write run results to file 705 self.write_all_results() 706 self.write_run_results()
Function to start the simulation.