class_hems_availability
1from datetime import datetime, timedelta 2from dateutil.relativedelta import relativedelta 3from typing import Any, Generator 4from class_patient import Patient 5from utils import Utils 6import pandas as pd 7from class_hems import HEMS 8from simpy import FilterStore, Event 9from numpy.random import SeedSequence 10 11import logging 12from enum import IntEnum 13 14class ResourceAllocationReason(IntEnum): 15 NONE_AVAILABLE = 0 16 MATCH_PREFERRED_CARE_CAT_HELI = 1 17 MATCH_PREFERRED_CARE_CAT_CAR = 2 18 CC_MATCH_EC_HELI = 3 19 CC_MATCH_EC_CAR = 4 20 EC_MATCH_CC_HELI = 5 21 EC_MATCH_CC_CAR = 6 22 REG_HELI_BENEFIT_MATCH_EC_HELI = 7 23 REG_HELI_BENEFIT_MATCH_CC_HELI = 8 24 REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE = 9 25 REG_NO_HELI_BENEFIT_GROUP = 10 26 OTHER_VEHICLE_TYPE = 11 27 REG_NO_HELI_BENEFIT_ANY = 12 28 REG_NO_HELI_BENEFIT_VEHICLE = 13 29 30class HEMSAvailability(): 31 """ 32 # The HEMS Availability class 33 34 This class is a filter store which can provide HEMS resources 35 based on the time of day and servicing schedule 36 37 38 """ 39 40 def __init__(self, env, sim_start_date, sim_duration, utility: Utils, servicing_overlap_allowed = False, 41 servicing_buffer_weeks = 4, servicing_preferred_month = 1, 42 print_debug_messages = False, master_seed=SeedSequence(42)): 43 44 self.LOOKUP_LIST = [ 45 "No HEMS resource available", #0 46 "Preferred HEMS care category and vehicle type match", # 1 47 "Preferred HEMS care category match but not vehicle type", # 2 48 "HEMS CC case EC helicopter available", # 3 49 "HEMS CC case EC car available", # 4 50 "HEMS EC case CC helicopter available", # 5 51 "HEMS EC case CC car available", # 6 52 "HEMS REG helicopter case EC helicopter available", 53 "HEMS REG helicopter case CC helicopter available", 54 "HEMS REG case no helicopter benefit preferred group and vehicle type allocated", # 9 55 "HEMS REG case no helicopter benefit preferred group allocated", # 10 56 "No HEMS resource available (pref vehicle type = 'Other')", # 11 57 "HEMS REG case no helicopter benefit first free resource allocated", # 12 58 "HEMS REG case no helicopter benefit free helicopter allocated" #13 59 ] 60 61 self.env = env 62 self.print_debug_messages = print_debug_messages 63 self.master_seed = master_seed 64 self.utilityClass = utility 65 66 # Adding options to set servicing parameters. 67 self.servicing_overlap_allowed = servicing_overlap_allowed 68 self.serviing_buffer_weeks = servicing_buffer_weeks 69 self.servicing_preferred_month = servicing_preferred_month 70 self.sim_start_date = sim_start_date 71 72 self.debug(f"Sim start date {self.sim_start_date}") 73 # For belts and braces, add an additional year to 74 # calculate the service schedules since service dates can be walked back to the 75 # previous year 76 self.sim_end_date = sim_start_date + timedelta(minutes=sim_duration + (1*365*24*60)) 77 78 # School holidays 79 self.school_holidays = pd.read_csv('actual_data/school_holidays.csv') 80 81 self.HEMS_resources_list = [] 82 83 self.active_callsign_groups = set() # Prevents same crew being used twice...hopefully... 84 self.active_registrations = set() # Prevents same vehicle being used twice 85 self.active_callsigns = set() 86 87 # Create a store for HEMS resources 88 self.store = FilterStore(env) 89 90 self.serviceStore = FilterStore(env) 91 92 # Prepare HEMS resources for ingesting into store 93 self.prep_HEMS_resources() 94 95 # Populate the store with HEMS resources 96 self.populate_store() 97 98 # Daily servicing check (in case sim starts during a service) 99 [dow, hod, weekday, month, qtr, current_dt] = self.utilityClass.date_time_of_call(self.sim_start_date, self.env.now) 100 101 self.daily_servicing_check(current_dt, hod, month) 102 103 def debug(self, message: str): 104 if self.print_debug_messages: 105 logging.debug(message) 106 #print(message) 107 108 def daily_servicing_check(self, current_dt: datetime, hour: int, month: int): 109 """ 110 Function to iterate through the store and trigger the service check 111 function in the HEMS class 112 """ 113 h: HEMS 114 115 self.debug('------ DAILY SERVICING CHECK -------') 116 117 GDAAS_service = False 118 119 all_resources = self.serviceStore.items + self.store.items 120 for h in all_resources: 121 if h.registration == 'g-daas': 122 GDAAS_service = h.unavailable_due_to_service(current_dt) 123 break 124 125 self.debug(f"GDAAS_service is {GDAAS_service}") 126 127 # --- Return from serviceStore to store --- 128 to_return = [ 129 (s.category, s.registration) 130 for s in self.serviceStore.items 131 if not s.service_check(current_dt, GDAAS_service) # Note the NOT here! 132 ] 133 134 # Attempted fix for gap after return from H70 duties 135 for h in self.store.items: 136 if h.registration == 'g-daan' and not GDAAS_service: 137 h.callsign_group = 71 138 h.callsign = 'H71' 139 140 if to_return: 141 self.debug("Service store has items to return") 142 143 for category, registration in to_return: 144 s = yield self.serviceStore.get( 145 lambda item: item.category == category and item.registration == registration 146 ) 147 yield self.store.put(s) 148 self.debug(f"Returned [{s.category} / {s.registration}] from service to store") 149 150 # --- Send from store to serviceStore --- 151 to_service = [ 152 (h.category, h.registration) 153 for h in self.store.items 154 if h.service_check(current_dt, GDAAS_service) 155 ] 156 157 for category, registration in to_service: 158 self.debug("****************") 159 self.debug(f"HEMS [{category} / {registration}] being serviced, removing from store") 160 161 h = yield self.store.get( 162 lambda item: item.category == category and item.registration == registration 163 ) 164 165 self.debug(f"HEMS [{h.category} / {h.registration}] successfully removed from store") 166 yield self.serviceStore.put(h) 167 self.debug(f"HEMS [{h.category} / {h.registration}] moved to service store") 168 self.debug("***********") 169 170 self.debug(self.current_store_status(hour, month)) 171 self.debug(self.current_store_status(hour, month, 'service')) 172 173 [dow, hod, weekday, month, qtr, current_dt] = self.utilityClass.date_time_of_call(self.sim_start_date, self.env.now) 174 for h in self.store.items: 175 if h.registration == 'g-daan': 176 self.debug(f"[{self.env.now}] g-daan status: in_use={h.in_use}, callsign={h.callsign}, group={h.callsign_group}, on_shift={h.hems_resource_on_shift(hod, month)}") 177 178 self.debug('------ END OF DAILY SERVICING CHECK -------') 179 180 181 def prep_HEMS_resources(self) -> None: 182 """ 183 This function ingests HEMS resource data from a user-supplied CSV file 184 and populates a list of HEMS class objects. The key activity here is 185 the calculation of service schedules for each HEMS object, taking into account a 186 user-specified preferred month of servicing, service duration, and a buffer period 187 following a service to allow for over-runs and school holidays 188 189 """ 190 191 schedule = [] 192 service_dates = [] 193 194 195 # Calculate service schedules for each resource 196 197 SERVICING_SCHEDULE = pd.read_csv('actual_data/service_schedules_by_model.csv') 198 SERVICE_HISTORY = pd.read_csv('actual_data/service_history.csv', na_values=0) 199 CALLSIGN_REGISTRATION = pd.read_csv('actual_data/callsign_registration_lookup.csv') 200 201 SERVICING_SCHEDULE = SERVICING_SCHEDULE.merge( 202 CALLSIGN_REGISTRATION, 203 how="right", 204 on="model" 205 ) 206 207 SERVICING_SCHEDULE = SERVICING_SCHEDULE.merge( 208 SERVICE_HISTORY, 209 how="left", 210 on="registration" 211 ) 212 213 self.debug(f"prep_hems_resources: schedule {SERVICING_SCHEDULE}") 214 215 for index, row in SERVICING_SCHEDULE.iterrows(): 216 #self.debug(row) 217 current_resource_service_dates = [] 218 # Check if service date provided 219 if not pd.isna(row['last_service']): 220 #self.debug(f"Checking {row['registration']} with previous service date of {row['last_service']}") 221 last_service = datetime.strptime(row['last_service'], "%Y-%m-%d") 222 service_date = last_service 223 224 while last_service < self.sim_end_date: 225 226 end_date = last_service + \ 227 timedelta(weeks = int(row['service_duration_weeks'])) + \ 228 timedelta(weeks=self.serviing_buffer_weeks) 229 230 service_date, end_date = self.find_next_service_date( 231 last_service, 232 row["service_schedule_months"], 233 service_dates, 234 row['service_duration_weeks'] 235 ) 236 237 schedule.append((row['registration'], service_date, end_date)) 238 #self.debug(service_date) 239 service_dates.append({'service_start_date': service_date, 'service_end_date': end_date}) 240 241 current_resource_service_dates.append({'service_start_date': service_date, 'service_end_date': end_date}) 242 #self.debug(service_dates) 243 #self.debug(current_resource_service_dates) 244 last_service = service_date 245 else: 246 schedule.append((row['registration'], None, None)) 247 #self.debug(schedule) 248 249 service_df = pd.DataFrame(schedule, columns=["registration", "service_start_date", "service_end_date"]) 250 251 service_df.to_csv("data/service_dates.csv", index=False) 252 253 # Append those to the HEMS resource. 254 255 HEMS_RESOURCES = ( 256 pd.read_csv("actual_data/HEMS_ROTA.csv") 257 # Add model and servicing rules 258 .merge( 259 SERVICING_SCHEDULE, 260 on=["callsign", "vehicle_type"], 261 how="left" 262 ) 263 ) 264 265 for index, row in HEMS_RESOURCES.iterrows(): 266 267 s = service_df[service_df['registration'] == row['registration']] 268 #self.debug(s) 269 270 # Create new HEMS resource and add to HEMS_resource_list 271 #pd.DataFrame(columns=['year', 'service_start_date', 'service_end_date']) 272 hems = HEMS( 273 callsign = row['callsign'], 274 callsign_group = row['callsign_group'], 275 vehicle_type = row['vehicle_type'], 276 category = row['category'], 277 registration = row['registration'], 278 summer_start = row['summer_start'], 279 winter_start = row['winter_start'], 280 summer_end = row['summer_end'], 281 winter_end = row['winter_end'], 282 servicing_schedule = s, 283 resource_id = row['registration'] 284 ) 285 286 self.HEMS_resources_list.append(hems) 287 288 #self.debug(self.HEMS_resources_list) 289 290 291 def populate_store(self): 292 """ 293 Function to populate the filestore with HEMS class objects 294 contained in a class list 295 """ 296 297 h: HEMS 298 for h in self.HEMS_resources_list: 299 self.debug(f"Populating resource store: HEMS({h.callsign})") 300 self.debug(h.servicing_schedule) 301 self.store.put(h) 302 303 304 def add_hems(self): 305 """ 306 Future function to allow for adding HEMS resources. 307 We might not use this (we could just amend the HEMS_ROTA dataframe, for example) 308 but might be useful for 'what if' simulations 309 """ 310 pass 311 312 # def resource_allocation_lookup(self, prefered_lookup: int): 313 # """ 314 # Function to return description of lookup allocation choice 315 316 # """ 317 318 # lookup_list = [ 319 # "No HEMS resource available", 320 # "Preferred HEMS care category and vehicle type match", 321 # "Preferred HEMS care category match but not vehicle type", 322 # "HEMS CC case EC helicopter available", 323 # "HEMS CC case EC car available", 324 # "HEMS EC case CC helicopter available", 325 # "HEMS EC case CC car available", 326 # "HEMS helicopter case EC helicopter available", 327 # "HEMS helicopter case CC helicopter available", 328 # "HEMS REG case no helicopter benefit preferred group and vehicle type allocated", 329 # "HEMS REG case no helicopter benefit preferred group allocated", 330 # "No HEMS resource available (pref vehicle type = 'Other')", 331 # "HEMS REG case no helicopter benefit first free resource allocated" 332 # ] 333 334 # return lookup_list[prefered_lookup] 335 336 def resource_allocation_lookup(self, reason: ResourceAllocationReason) -> str: 337 return self.LOOKUP_LIST[reason.value] 338 339 def current_store_status(self, hour, month, store = 'resource') -> list[str]: 340 """ 341 Debugging function to return current state of store 342 """ 343 344 current_store_items = [] 345 346 h: HEMS 347 348 if store == 'resource': 349 for h in self.store.items: 350 current_store_items.append(f"{h.callsign} ({h.category} online: {h.hems_resource_on_shift(hour, month)} {h.registration})") 351 else: 352 for h in self.serviceStore.items: 353 current_store_items.append(f"{h.callsign} ({h.category} online: {h.hems_resource_on_shift(hour, month)} {h.registration})") 354 355 return current_store_items 356 357 358 # def preferred_resource_available(self, pt: Patient) -> list[HEMS | None, str]: 359 360 # """ 361 # Check whether the preferred resource group is available. Returns a list with either the HEMS resource or None, and 362 # an indication as to whether the resource was available, or another resource in an established hierachy of 363 # availability can be allocated 364 # """ 365 366 # # Initialise object HEMS as a placeholder object 367 # hems: HEMS | None = None 368 # # Initialise variable 'preferred' to False 369 # preferred = 999 # This will be used to ensure that the most desireable resource 370 # # is allocated given that multiple matches may be found 371 # preferred_lookup = 0 # This will be used to code the resource allocation choice 372 373 # preferred_care_category = pt.hems_cc_or_ec 374 375 # self.debug(f"EC/CC resource with {preferred_care_category} and hour {pt.hour} and qtr {pt.qtr}") 376 377 # h: HEMS 378 # for h in self.store.items: 379 380 # # There is a hierachy of calls: 381 # # CC = H70 helicopter then car, then H71 helicopter then car then CC72 382 # # EC = H71 helicopter then car, then CC72, then H70 helicopter then car 383 # # If no resources then return None 384 385 # if not h.in_use and h.hems_resource_on_shift(pt.hour, pt.qtr): 386 # # self.debug(f"Checking whether to generate ad-hoc reason for {h} ({h.vehicle_type} - {h.callsign_group})") 387 388 # if ( # Skip this resource if any of the following are true: 389 # h.in_use or 390 # h.being_serviced or 391 # not h.hems_resource_on_shift(pt.hour, pt.qtr) or 392 # # Skip if crew is already in use 393 # h.callsign_group in self.active_callsign_groups or 394 # h.registration in self.active_registrations 395 # ): 396 # # self.debug(f"Skipping ad-hoc unavailability check for {h}") 397 # continue 398 399 # if h.vehicle_type == "car": 400 # ad_hoc_reason = "available" 401 # else: 402 # ad_hoc_reason = self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 403 404 # self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} ({pt.hems_cc_or_ec}) is: {ad_hoc_reason}") 405 406 # if ad_hoc_reason != "available": 407 # continue 408 409 # if h.category == preferred_care_category and h.vehicle_type == "helicopter" and not h.being_serviced: 410 # hems = h 411 # preferred = 1 # Top choice 412 # preferred_lookup = 1 413 # return [hems, self.resource_allocation_lookup(preferred_lookup)] 414 415 # elif h.category == preferred_care_category and not h.being_serviced: 416 # hems = h 417 # preferred = 2 # Second choice (correct care category, but not vehicle type) 418 # preferred_lookup = 2 419 420 # elif preferred_care_category == 'CC': 421 # if h.vehicle_type == 'helicopter' and h.category == 'EC' and not h.being_serviced: 422 # if preferred > 3: 423 # hems = h 424 # preferred = 3 # Third choice (EC helicopter) 425 # preferred_lookup = 3 426 427 # elif h.category == 'EC' and not h.being_serviced: 428 # if preferred > 4: 429 # hems = h 430 # preferred = 4 431 # preferred_lookup = 4 432 433 # elif preferred_care_category == 'EC': 434 # if h.vehicle_type == 'helicopter' and h.category == 'CC' and not h.being_serviced: 435 # if preferred > 3: 436 # hems = h 437 # preferred = 3 # CC helicopter available 438 # preferred_lookup = 5 439 440 # elif h.category == 'CC' and not h.being_serviced: 441 # hems = h 442 # preferred = 4 443 # preferred_lookup = 6 444 445 # self.debug(f"preferred lookup {preferred_lookup} and preferred = {preferred}") 446 447 # if preferred_lookup != 999: 448 # return [hems, self.resource_allocation_lookup(preferred_lookup)] 449 # else: 450 # return [None, self.resource_allocation_lookup(0)] 451 452 def preferred_resource_available(self, pt: Patient) -> list[HEMS | None, str]: 453 """ 454 Determine the best available HEMS resource for an EC/CC case based on the 455 patient's preferred care category (EC = Enhanced Care, CC = Critical Care), 456 vehicle type, and ad-hoc availability. Returns the chosen HEMS unit and 457 the reason for its selection. 458 459 Returns: 460 A list containing: 461 - The selected HEMS unit (or None if none available) 462 - A lookup code describing the allocation reason 463 """ 464 # Retrieve patient’s preferred care category 465 preferred_category = pt.hems_cc_or_ec 466 self.debug(f"EC/CC resource with {preferred_category} and hour {pt.hour} and qtr {pt.qtr}") 467 468 best_hems: HEMS | None = None # Best-matching HEMS unit found so far 469 best_priority = float('inf') # Lower values = better matches 470 best_lookup = ResourceAllocationReason.NONE_AVAILABLE # Reason for final allocation 471 472 for h in self.store.items: 473 # --- FILTER OUT UNAVAILABLE RESOURCES --- 474 if ( 475 h.in_use or # Already dispatched 476 h.being_serviced or # Currently under maintenance 477 not h.hems_resource_on_shift(pt.hour, pt.month) or # Not scheduled for shift now 478 h.callsign_group in self.active_callsign_groups or # Another unit from this group is active (so crew is engaged elsewhere) 479 h.registration in self.active_registrations # This specific unit is already dispatched 480 ): 481 continue # Move to the next HEMS unit 482 483 # Check ad-hoc reason 484 # For "car" units, assume always available. 485 # For helicopters, simulate availability using ad-hoc logic (e.g., weather, servicing). 486 reason = "available" if h.vehicle_type == "car" else self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 487 self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} ({pt.hems_cc_or_ec}) is: {reason}") 488 489 if reason != "available": 490 continue # Skip this unit if not usable 491 492 # Decide priority and reason 493 priority = None 494 lookup = None 495 496 # 1. Best case: resource category matches preferred care category *and* is a helicopter 497 if h.category == preferred_category and h.vehicle_type == "helicopter": 498 priority = 1 499 lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_HELI 500 501 # 2. Next best: resource category matches preferred care category, but is a car 502 elif h.category == preferred_category: 503 priority = 2 504 lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_CAR 505 506 # 3–4. Category mismatch fallback options: 507 # For a CC preference, fall back to EC providers if needed 508 elif preferred_category == "CC": 509 if h.category == "EC" and h.vehicle_type == "helicopter": 510 priority = 3 511 lookup = ResourceAllocationReason.CC_MATCH_EC_HELI 512 elif h.category == "EC": 513 priority = 4 514 lookup = ResourceAllocationReason.CC_MATCH_EC_CAR 515 516 # For an EC preference, fall back to CC providers if needed 517 elif preferred_category == "EC": 518 if h.category == "CC" and h.vehicle_type == "helicopter": 519 priority = 3 520 lookup = ResourceAllocationReason.EC_MATCH_CC_HELI 521 elif h.category == "CC": 522 priority = 4 523 lookup = ResourceAllocationReason.EC_MATCH_CC_CAR 524 525 # --- CHECK IF THIS IS THE BEST OPTION SO FAR --- 526 if priority is not None and priority < best_priority: 527 best_hems = h 528 best_priority = priority 529 best_lookup = lookup 530 531 # Immediate return if best possible match found (priority 1) 532 if priority == 1: 533 self.debug(f"Top priority match found: {best_lookup.name} ({best_lookup.value})") 534 return [best_hems, self.resource_allocation_lookup(best_lookup)] 535 536 # Final fallback: return the best match found (if any), or none with reason 537 self.debug(f"Selected best lookup: {best_lookup.name} ({best_lookup.value}) with priority = {best_priority}") 538 return [best_hems, self.resource_allocation_lookup(best_lookup)] 539 540 541 def allocate_resource(self, pt: Patient) -> Any | Event: 542 """ 543 Attempt to allocate a resource from the preferred group. 544 """ 545 resource_event: Event = self.env.event() 546 547 def process() -> Generator[Any, Any, None]: 548 self.debug(f"Allocating resource for {pt.id} and care cat {pt.hems_cc_or_ec}") 549 550 pref_res: list[HEMS | None, str] = self.preferred_resource_available(pt) 551 552 if pref_res[0] is None: 553 return resource_event.succeed([None, pref_res[1], None]) 554 555 primary = pref_res[0] 556 557 # Block if in-use by callsign group, registration, or callsign 558 if primary.callsign_group in self.active_callsign_groups: 559 self.debug(f"[BLOCKED] Callsign group {primary.callsign_group} in use") 560 return resource_event.succeed([None, pref_res[1], None]) 561 562 if primary.registration in self.active_registrations: 563 self.debug(f"[BLOCKED] Registration {primary.registration} in use") 564 return resource_event.succeed([None, pref_res[1], None]) 565 566 if primary.callsign in self.active_callsigns: 567 self.debug(f"[BLOCKED] Callsign {primary.callsign} already in use") 568 return resource_event.succeed([None, pref_res[1], None]) 569 570 self.active_callsign_groups.add(primary.callsign_group) 571 self.active_registrations.add(primary.registration) 572 self.active_callsigns.add(primary.callsign) 573 574 with self.store.get(lambda r: r == primary) as primary_request: 575 result = yield primary_request | self.env.timeout(0.1) 576 577 if primary_request in result: 578 primary.in_use = True 579 pt.hems_callsign_group = primary.callsign_group 580 pt.hems_vehicle_type = primary.vehicle_type 581 pt.hems_category = primary.category 582 583 # self.active_callsign_groups.add(primary.callsign_group) 584 # self.active_registrations.add(primary.registration) 585 # self.active_callsigns.add(primary.callsign) 586 587 # Try to get a secondary resource 588 with self.store.get(lambda r: 589 r != primary and 590 r.callsign_group == pt.hems_callsign_group and 591 r.category == pt.hems_category and 592 r.hems_resource_on_shift(pt.hour, pt.month) and 593 r.callsign_group not in self.active_callsign_groups and 594 r.registration not in self.active_registrations and 595 r.callsign not in self.active_callsigns 596 ) as secondary_request: 597 598 result2 = yield secondary_request | self.env.timeout(0.1) 599 secondary = None 600 601 if secondary_request in result2: 602 secondary = result2[secondary_request] 603 secondary.in_use = True 604 self.active_callsign_groups.add(secondary.callsign_group) 605 self.active_registrations.add(secondary.registration) 606 self.active_callsigns.add(secondary.callsign) 607 608 return resource_event.succeed([primary, pref_res[1], secondary]) 609 else: 610 # Roll back if unsuccessful 611 self.active_callsign_groups.discard(primary.callsign_group) 612 self.active_registrations.discard(primary.registration) 613 self.active_callsigns.discard(primary.callsign) 614 615 return resource_event.succeed([None, pref_res[1], None]) 616 617 self.env.process(process()) 618 return resource_event 619 620 621 def return_resource(self, resource: HEMS, secondary_resource: HEMS|None) -> None: 622 """ 623 Class to return HEMS class object back to the filestore. 624 """ 625 626 resource.in_use = False 627 self.active_callsign_groups.discard(resource.callsign_group) 628 self.active_registrations.discard(resource.registration) 629 self.active_callsigns.discard(resource.callsign) 630 self.store.put(resource) 631 self.debug(f"{resource.callsign} finished job") 632 633 if secondary_resource is not None: 634 secondary_resource.in_use = False 635 self.active_callsign_groups.discard(secondary_resource.callsign_group) 636 self.active_registrations.discard(secondary_resource.registration) 637 self.active_callsigns.discard(secondary_resource.callsign) 638 self.store.put(secondary_resource) 639 self.debug(f"{secondary_resource.callsign} free as {resource.callsign} finished job") 640 641 642 643 def years_between(self, start_date: datetime, end_date: datetime) -> list[int]: 644 """ 645 Function to return a list of years between given start and end date 646 """ 647 return list(range(start_date.year, end_date.year + 1)) 648 649 650 def do_ranges_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool: 651 """ 652 Function to determine whether two sets of datetimes overlap 653 """ 654 return max(start1, start2) <= min(end1, end2) 655 656 657 def is_during_school_holidays(self, start_date: datetime, end_date: datetime) -> bool: 658 """ 659 Function to calculate whether given start and end date time period falls within 660 a school holiday 661 """ 662 663 for index, row in self.school_holidays.iterrows(): 664 665 if self.do_ranges_overlap(pd.to_datetime(row['start_date']), pd.to_datetime(row['end_date']), start_date, end_date): 666 return True 667 668 return False 669 670 671 def is_other_resource_being_serviced(self, start_date, end_date, service_dates): 672 """ 673 Function to determine whether any resource is being services between a 674 given start and end date period. 675 """ 676 677 for sd in service_dates: 678 if self.do_ranges_overlap(sd['service_start_date'], sd['service_end_date'], start_date, end_date): 679 return True 680 681 return False 682 683 684 def find_next_service_date(self, last_service_date: datetime, interval_months: int, service_dates: list, service_duration: int) -> list[datetime]: 685 """ 686 Function to determine the next service date for a resource. The date is determine by 687 the servicing schedule for the resource, the preferred month of servicing, and to 688 avoid dates that fall in either school holidays or when other resources are being serviced. 689 """ 690 691 next_due_date = last_service_date + relativedelta(months = interval_months) # Approximate month length 692 end_date = next_due_date + timedelta(weeks = service_duration) 693 694 preferred_date = datetime(next_due_date.year, self.servicing_preferred_month , 2) 695 preferred_end_date = preferred_date + timedelta(weeks = service_duration) 696 697 if next_due_date.month > preferred_date.month: 698 preferred_date += relativedelta(years = 1) 699 700 # self.debug(f"Next due: {next_due_date} with end date {end_date} and preferred_date is {preferred_date} with pref end {preferred_end_date}") 701 702 # If preferred date is valid, use it 703 if preferred_date <= next_due_date and not self.is_during_school_holidays(preferred_date, preferred_end_date): 704 next_due_date = preferred_date 705 706 while True: 707 if self.is_during_school_holidays(next_due_date, end_date) or self.is_other_resource_being_serviced(next_due_date, end_date, service_dates): 708 next_due_date -= timedelta(days = 1) 709 end_date = next_due_date + timedelta(weeks = service_duration) 710 continue 711 else: 712 break 713 714 return [next_due_date, end_date] 715 716 717 # def preferred_regular_group_available(self, pt: Patient) -> list[HEMS | None, str]: 718 # """ 719 # Check availability for REG jobs while avoiding assigning two units from 720 # the same crew/callsign group. 721 # """ 722 # hems: HEMS | None = None 723 # preferred = 999 724 # preferred_lookup = 0 725 726 # preferred_group = pt.hems_pref_callsign_group 727 # preferred_vehicle_type = pt.hems_pref_vehicle_type 728 # helicopter_benefit = pt.hems_helicopter_benefit 729 730 # for h in self.store.items: 731 732 # if ( 733 # h.in_use or 734 # h.being_serviced or 735 # not h.hems_resource_on_shift(pt.hour, pt.qtr) or 736 # # Skip if crew is already in use 737 # h.callsign_group in self.active_callsign_groups or 738 # h.registration in self.active_registrations 739 # ): 740 # continue 741 742 743 # if h.vehicle_type == "car": 744 # ad_hoc_reason = "available" 745 # else: 746 # ad_hoc_reason = self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 747 748 # self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} (REG) is: {ad_hoc_reason}") 749 750 # if ad_hoc_reason != "available": 751 # continue 752 753 # # Helicopter benefit cases 754 # if helicopter_benefit == 'y': 755 # if h.vehicle_type == 'helicopter' and h.category == 'EC': 756 # hems = h 757 # preferred_lookup = 7 758 # break 759 # elif h.vehicle_type == 'helicopter': 760 # if preferred > 2: 761 # hems = h 762 # preferred = 2 763 # preferred_lookup = 8 764 765 # # Regular (non-helicopter) cases 766 # else: 767 # if (h.callsign_group == preferred_group 768 # and h.vehicle_type == preferred_vehicle_type): 769 # hems = h 770 # preferred_lookup = 9 771 # break 772 # elif h.callsign_group == preferred_group: 773 # if preferred > 4: 774 # hems = h 775 # preferred = 4 776 # preferred_lookup = 10 777 # else: 778 # if preferred > 5: 779 # hems = h 780 # preferred = 5 781 # preferred_lookup = 12 782 783 # return [hems, self.resource_allocation_lookup(preferred_lookup if hems else 0)] 784 785 def preferred_regular_group_available(self, pt: Patient) -> list[HEMS | None, str]: 786 """ 787 Determine the most suitable HEMS (Helicopter Emergency Medical Service) unit 788 for a REG (regular) job, ensuring that two units from the same crew/callsign 789 group are not simultaneously active. This method prioritizes matching the 790 patient's preferences and helicopter benefit where applicable. 791 792 Returns: 793 A list containing: 794 - The selected HEMS unit (or None if none available) 795 - A lookup code describing the allocation reason 796 """ 797 # Initialize selection variables 798 hems: HEMS | None = None # The selected resource 799 preferred = float("inf") # Lower numbers mean more preferred options (2, 4, 5 are used below) 800 preferred_lookup = ResourceAllocationReason.NONE_AVAILABLE # Reason code for selection 801 802 preferred_group = pt.hems_pref_callsign_group # Preferred crew group 803 preferred_vehicle_type = pt.hems_pref_vehicle_type # e.g., "car" or "helicopter" 804 helicopter_benefit = pt.hems_helicopter_benefit # "y" if helicopter has clinical benefit 805 # Iterate through all HEMS resources stored 806 for h in self.store.items: 807 if ( 808 h.in_use or # Already dispatched 809 h.being_serviced or # Currently under maintenance 810 not h.hems_resource_on_shift(pt.hour, pt.month) or # Not scheduled for shift now 811 h.callsign_group in self.active_callsign_groups or # Another unit from this group is active (so crew is engaged elsewhere) 812 h.registration in self.active_registrations # This specific unit is already dispatched 813 ): 814 continue # Move to the next HEMS unit 815 816 # Check ad hoc availability 817 # For "car" units, assume always available. 818 # For helicopters, simulate availability using ad-hoc logic (e.g., weather, servicing). 819 reason = "available" if h.vehicle_type == "car" else self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 820 self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} (REG) is: {reason}") 821 822 if reason != "available": 823 continue # Skip this unit if not usable 824 825 # --- HELICOPTER BENEFIT CASE --- 826 # P3 = Helicopter patient 827 # Resources allocated in following order: 828 # IF H70 available = SEND 829 # ELSE H71 available = SEND 830 831 # Assume 30% chance of knowing in advance that it's going to be a heli benefit case 832 # when choosing to send a resource (and if you know that, don't fall back to 833 # sending a car) 834 if helicopter_benefit == "y" and self.utilityClass.rngs["know_heli_benefit"].uniform(0, 1) <= 0.5: 835 # Priority 1: CC-category helicopter (assumed most beneficial) 836 if h.vehicle_type == "helicopter" and h.category == "CC": 837 hems = h 838 preferred_lookup = ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_CC_HELI 839 break 840 # Priority 2: Any helicopter (less preferred than CC, hence priority = 2) 841 elif h.vehicle_type == "helicopter" and preferred > 2: 842 hems = h 843 preferred = 2 844 preferred_lookup = ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_EC_HELI 845 846 847 # If no EC or CC helicopters are available, then: 848 # - hems remains None 849 # - preferred_lookup remains at its initial value (ResourceAllocationReason.NONE_AVAILABLE) 850 # - The function exits the loop without assigning a resource. 851 852 # --- REGULAR JOB WITH NO SIMULATED HELICOPTER BENEFIT --- 853 else: 854 # Best match: matching both preferred callsign group and vehicle type 855 if h.callsign_group == preferred_group and h.vehicle_type == preferred_vehicle_type: 856 hems = h 857 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE 858 break 859 # Next best: send a helicopter 860 elif h.vehicle_type == "helicopter" and preferred > 3: 861 hems = h 862 preferred = 3 863 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_VEHICLE 864 # Next best: match only on preferred callsign group 865 elif h.callsign_group == preferred_group and preferred > 4: 866 hems = h 867 preferred = 4 868 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP 869 # Fallback: any available resource 870 elif preferred > 5: 871 hems = h 872 preferred = 5 873 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_ANY 874 875 # Return the best found HEMS resource and reason for selection 876 self.debug(f"Selected REG (heli benefit = {helicopter_benefit}) lookup: {preferred_lookup.name} ({preferred_lookup.value})") 877 return [hems, self.resource_allocation_lookup(preferred_lookup if hems else ResourceAllocationReason.NONE_AVAILABLE)] 878 879 880 def allocate_regular_resource(self, pt: Patient) -> Any | Event: 881 """ 882 Attempt to allocate a resource from the preferred group (REG jobs). 883 """ 884 resource_event: Event = self.env.event() 885 886 def process() -> Generator[Any, Any, None]: 887 # if pt.hems_pref_vehicle_type == "Other": 888 # pref_res = [None, self.resource_allocation_lookup(11)] 889 # else: 890 pref_res = self.preferred_regular_group_available(pt) 891 892 if pref_res[0] is None: 893 return resource_event.succeed([None, pref_res[1], None]) 894 895 primary = pref_res[0] 896 897 # Block if in-use by callsign group, registration, or callsign 898 if primary.callsign_group in self.active_callsign_groups: 899 self.debug(f"[BLOCKED] Regular Callsign group {primary.callsign_group} in use") 900 return resource_event.succeed([None, pref_res[1], None]) 901 902 if primary.registration in self.active_registrations: 903 self.debug(f"[BLOCKED] Regular Registration {primary.registration} in use") 904 return resource_event.succeed([None, pref_res[1], None]) 905 906 if primary.callsign in self.active_callsigns: 907 self.debug(f"[BLOCKED] Regular Callsign {primary.callsign} already in use") 908 return resource_event.succeed([None, pref_res[1], None]) 909 910 self.active_callsign_groups.add(primary.callsign_group) 911 self.active_registrations.add(primary.registration) 912 self.active_callsigns.add(primary.callsign) 913 914 with self.store.get(lambda r: r == primary) as primary_request: 915 result = yield primary_request | self.env.timeout(0.1) 916 917 if primary_request in result: 918 primary.in_use = True 919 pt.hems_callsign_group = primary.callsign_group 920 pt.hems_vehicle_type = primary.vehicle_type 921 pt.hems_category = primary.category 922 923 # self.active_callsign_groups.add(primary.callsign_group) 924 # self.active_registrations.add(primary.registration) 925 # self.active_callsigns.add(primary.callsign) 926 927 # Try to get a secondary resource 928 with self.store.get(lambda r: 929 r != primary and 930 r.callsign_group == pt.hems_callsign_group and 931 r.category == pt.hems_category and 932 r.hems_resource_on_shift(pt.hour, pt.month) and 933 r.callsign_group not in self.active_callsign_groups and 934 r.registration not in self.active_registrations and 935 r.callsign not in self.active_callsigns 936 ) as secondary_request: 937 938 result2 = yield secondary_request | self.env.timeout(0.1) 939 secondary = None 940 941 if secondary_request in result2: 942 secondary = result2[secondary_request] 943 secondary.in_use = True 944 self.active_callsign_groups.add(secondary.callsign_group) 945 self.active_registrations.add(secondary.registration) 946 self.active_callsigns.add(secondary.callsign) 947 948 return resource_event.succeed([primary, pref_res[1], secondary]) 949 950 else: 951 # Roll back if unsuccessful 952 self.active_callsign_groups.discard(primary.callsign_group) 953 self.active_registrations.discard(primary.registration) 954 self.active_callsigns.discard(primary.callsign) 955 956 return resource_event.succeed([None, pref_res[1], None]) 957 958 self.env.process(process()) 959 return resource_event
15class ResourceAllocationReason(IntEnum): 16 NONE_AVAILABLE = 0 17 MATCH_PREFERRED_CARE_CAT_HELI = 1 18 MATCH_PREFERRED_CARE_CAT_CAR = 2 19 CC_MATCH_EC_HELI = 3 20 CC_MATCH_EC_CAR = 4 21 EC_MATCH_CC_HELI = 5 22 EC_MATCH_CC_CAR = 6 23 REG_HELI_BENEFIT_MATCH_EC_HELI = 7 24 REG_HELI_BENEFIT_MATCH_CC_HELI = 8 25 REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE = 9 26 REG_NO_HELI_BENEFIT_GROUP = 10 27 OTHER_VEHICLE_TYPE = 11 28 REG_NO_HELI_BENEFIT_ANY = 12 29 REG_NO_HELI_BENEFIT_VEHICLE = 13
Inherited Members
- enum.Enum
- name
- value
- builtins.int
- conjugate
- bit_length
- bit_count
- to_bytes
- from_bytes
- as_integer_ratio
- is_integer
- real
- imag
- numerator
- denominator
31class HEMSAvailability(): 32 """ 33 # The HEMS Availability class 34 35 This class is a filter store which can provide HEMS resources 36 based on the time of day and servicing schedule 37 38 39 """ 40 41 def __init__(self, env, sim_start_date, sim_duration, utility: Utils, servicing_overlap_allowed = False, 42 servicing_buffer_weeks = 4, servicing_preferred_month = 1, 43 print_debug_messages = False, master_seed=SeedSequence(42)): 44 45 self.LOOKUP_LIST = [ 46 "No HEMS resource available", #0 47 "Preferred HEMS care category and vehicle type match", # 1 48 "Preferred HEMS care category match but not vehicle type", # 2 49 "HEMS CC case EC helicopter available", # 3 50 "HEMS CC case EC car available", # 4 51 "HEMS EC case CC helicopter available", # 5 52 "HEMS EC case CC car available", # 6 53 "HEMS REG helicopter case EC helicopter available", 54 "HEMS REG helicopter case CC helicopter available", 55 "HEMS REG case no helicopter benefit preferred group and vehicle type allocated", # 9 56 "HEMS REG case no helicopter benefit preferred group allocated", # 10 57 "No HEMS resource available (pref vehicle type = 'Other')", # 11 58 "HEMS REG case no helicopter benefit first free resource allocated", # 12 59 "HEMS REG case no helicopter benefit free helicopter allocated" #13 60 ] 61 62 self.env = env 63 self.print_debug_messages = print_debug_messages 64 self.master_seed = master_seed 65 self.utilityClass = utility 66 67 # Adding options to set servicing parameters. 68 self.servicing_overlap_allowed = servicing_overlap_allowed 69 self.serviing_buffer_weeks = servicing_buffer_weeks 70 self.servicing_preferred_month = servicing_preferred_month 71 self.sim_start_date = sim_start_date 72 73 self.debug(f"Sim start date {self.sim_start_date}") 74 # For belts and braces, add an additional year to 75 # calculate the service schedules since service dates can be walked back to the 76 # previous year 77 self.sim_end_date = sim_start_date + timedelta(minutes=sim_duration + (1*365*24*60)) 78 79 # School holidays 80 self.school_holidays = pd.read_csv('actual_data/school_holidays.csv') 81 82 self.HEMS_resources_list = [] 83 84 self.active_callsign_groups = set() # Prevents same crew being used twice...hopefully... 85 self.active_registrations = set() # Prevents same vehicle being used twice 86 self.active_callsigns = set() 87 88 # Create a store for HEMS resources 89 self.store = FilterStore(env) 90 91 self.serviceStore = FilterStore(env) 92 93 # Prepare HEMS resources for ingesting into store 94 self.prep_HEMS_resources() 95 96 # Populate the store with HEMS resources 97 self.populate_store() 98 99 # Daily servicing check (in case sim starts during a service) 100 [dow, hod, weekday, month, qtr, current_dt] = self.utilityClass.date_time_of_call(self.sim_start_date, self.env.now) 101 102 self.daily_servicing_check(current_dt, hod, month) 103 104 def debug(self, message: str): 105 if self.print_debug_messages: 106 logging.debug(message) 107 #print(message) 108 109 def daily_servicing_check(self, current_dt: datetime, hour: int, month: int): 110 """ 111 Function to iterate through the store and trigger the service check 112 function in the HEMS class 113 """ 114 h: HEMS 115 116 self.debug('------ DAILY SERVICING CHECK -------') 117 118 GDAAS_service = False 119 120 all_resources = self.serviceStore.items + self.store.items 121 for h in all_resources: 122 if h.registration == 'g-daas': 123 GDAAS_service = h.unavailable_due_to_service(current_dt) 124 break 125 126 self.debug(f"GDAAS_service is {GDAAS_service}") 127 128 # --- Return from serviceStore to store --- 129 to_return = [ 130 (s.category, s.registration) 131 for s in self.serviceStore.items 132 if not s.service_check(current_dt, GDAAS_service) # Note the NOT here! 133 ] 134 135 # Attempted fix for gap after return from H70 duties 136 for h in self.store.items: 137 if h.registration == 'g-daan' and not GDAAS_service: 138 h.callsign_group = 71 139 h.callsign = 'H71' 140 141 if to_return: 142 self.debug("Service store has items to return") 143 144 for category, registration in to_return: 145 s = yield self.serviceStore.get( 146 lambda item: item.category == category and item.registration == registration 147 ) 148 yield self.store.put(s) 149 self.debug(f"Returned [{s.category} / {s.registration}] from service to store") 150 151 # --- Send from store to serviceStore --- 152 to_service = [ 153 (h.category, h.registration) 154 for h in self.store.items 155 if h.service_check(current_dt, GDAAS_service) 156 ] 157 158 for category, registration in to_service: 159 self.debug("****************") 160 self.debug(f"HEMS [{category} / {registration}] being serviced, removing from store") 161 162 h = yield self.store.get( 163 lambda item: item.category == category and item.registration == registration 164 ) 165 166 self.debug(f"HEMS [{h.category} / {h.registration}] successfully removed from store") 167 yield self.serviceStore.put(h) 168 self.debug(f"HEMS [{h.category} / {h.registration}] moved to service store") 169 self.debug("***********") 170 171 self.debug(self.current_store_status(hour, month)) 172 self.debug(self.current_store_status(hour, month, 'service')) 173 174 [dow, hod, weekday, month, qtr, current_dt] = self.utilityClass.date_time_of_call(self.sim_start_date, self.env.now) 175 for h in self.store.items: 176 if h.registration == 'g-daan': 177 self.debug(f"[{self.env.now}] g-daan status: in_use={h.in_use}, callsign={h.callsign}, group={h.callsign_group}, on_shift={h.hems_resource_on_shift(hod, month)}") 178 179 self.debug('------ END OF DAILY SERVICING CHECK -------') 180 181 182 def prep_HEMS_resources(self) -> None: 183 """ 184 This function ingests HEMS resource data from a user-supplied CSV file 185 and populates a list of HEMS class objects. The key activity here is 186 the calculation of service schedules for each HEMS object, taking into account a 187 user-specified preferred month of servicing, service duration, and a buffer period 188 following a service to allow for over-runs and school holidays 189 190 """ 191 192 schedule = [] 193 service_dates = [] 194 195 196 # Calculate service schedules for each resource 197 198 SERVICING_SCHEDULE = pd.read_csv('actual_data/service_schedules_by_model.csv') 199 SERVICE_HISTORY = pd.read_csv('actual_data/service_history.csv', na_values=0) 200 CALLSIGN_REGISTRATION = pd.read_csv('actual_data/callsign_registration_lookup.csv') 201 202 SERVICING_SCHEDULE = SERVICING_SCHEDULE.merge( 203 CALLSIGN_REGISTRATION, 204 how="right", 205 on="model" 206 ) 207 208 SERVICING_SCHEDULE = SERVICING_SCHEDULE.merge( 209 SERVICE_HISTORY, 210 how="left", 211 on="registration" 212 ) 213 214 self.debug(f"prep_hems_resources: schedule {SERVICING_SCHEDULE}") 215 216 for index, row in SERVICING_SCHEDULE.iterrows(): 217 #self.debug(row) 218 current_resource_service_dates = [] 219 # Check if service date provided 220 if not pd.isna(row['last_service']): 221 #self.debug(f"Checking {row['registration']} with previous service date of {row['last_service']}") 222 last_service = datetime.strptime(row['last_service'], "%Y-%m-%d") 223 service_date = last_service 224 225 while last_service < self.sim_end_date: 226 227 end_date = last_service + \ 228 timedelta(weeks = int(row['service_duration_weeks'])) + \ 229 timedelta(weeks=self.serviing_buffer_weeks) 230 231 service_date, end_date = self.find_next_service_date( 232 last_service, 233 row["service_schedule_months"], 234 service_dates, 235 row['service_duration_weeks'] 236 ) 237 238 schedule.append((row['registration'], service_date, end_date)) 239 #self.debug(service_date) 240 service_dates.append({'service_start_date': service_date, 'service_end_date': end_date}) 241 242 current_resource_service_dates.append({'service_start_date': service_date, 'service_end_date': end_date}) 243 #self.debug(service_dates) 244 #self.debug(current_resource_service_dates) 245 last_service = service_date 246 else: 247 schedule.append((row['registration'], None, None)) 248 #self.debug(schedule) 249 250 service_df = pd.DataFrame(schedule, columns=["registration", "service_start_date", "service_end_date"]) 251 252 service_df.to_csv("data/service_dates.csv", index=False) 253 254 # Append those to the HEMS resource. 255 256 HEMS_RESOURCES = ( 257 pd.read_csv("actual_data/HEMS_ROTA.csv") 258 # Add model and servicing rules 259 .merge( 260 SERVICING_SCHEDULE, 261 on=["callsign", "vehicle_type"], 262 how="left" 263 ) 264 ) 265 266 for index, row in HEMS_RESOURCES.iterrows(): 267 268 s = service_df[service_df['registration'] == row['registration']] 269 #self.debug(s) 270 271 # Create new HEMS resource and add to HEMS_resource_list 272 #pd.DataFrame(columns=['year', 'service_start_date', 'service_end_date']) 273 hems = HEMS( 274 callsign = row['callsign'], 275 callsign_group = row['callsign_group'], 276 vehicle_type = row['vehicle_type'], 277 category = row['category'], 278 registration = row['registration'], 279 summer_start = row['summer_start'], 280 winter_start = row['winter_start'], 281 summer_end = row['summer_end'], 282 winter_end = row['winter_end'], 283 servicing_schedule = s, 284 resource_id = row['registration'] 285 ) 286 287 self.HEMS_resources_list.append(hems) 288 289 #self.debug(self.HEMS_resources_list) 290 291 292 def populate_store(self): 293 """ 294 Function to populate the filestore with HEMS class objects 295 contained in a class list 296 """ 297 298 h: HEMS 299 for h in self.HEMS_resources_list: 300 self.debug(f"Populating resource store: HEMS({h.callsign})") 301 self.debug(h.servicing_schedule) 302 self.store.put(h) 303 304 305 def add_hems(self): 306 """ 307 Future function to allow for adding HEMS resources. 308 We might not use this (we could just amend the HEMS_ROTA dataframe, for example) 309 but might be useful for 'what if' simulations 310 """ 311 pass 312 313 # def resource_allocation_lookup(self, prefered_lookup: int): 314 # """ 315 # Function to return description of lookup allocation choice 316 317 # """ 318 319 # lookup_list = [ 320 # "No HEMS resource available", 321 # "Preferred HEMS care category and vehicle type match", 322 # "Preferred HEMS care category match but not vehicle type", 323 # "HEMS CC case EC helicopter available", 324 # "HEMS CC case EC car available", 325 # "HEMS EC case CC helicopter available", 326 # "HEMS EC case CC car available", 327 # "HEMS helicopter case EC helicopter available", 328 # "HEMS helicopter case CC helicopter available", 329 # "HEMS REG case no helicopter benefit preferred group and vehicle type allocated", 330 # "HEMS REG case no helicopter benefit preferred group allocated", 331 # "No HEMS resource available (pref vehicle type = 'Other')", 332 # "HEMS REG case no helicopter benefit first free resource allocated" 333 # ] 334 335 # return lookup_list[prefered_lookup] 336 337 def resource_allocation_lookup(self, reason: ResourceAllocationReason) -> str: 338 return self.LOOKUP_LIST[reason.value] 339 340 def current_store_status(self, hour, month, store = 'resource') -> list[str]: 341 """ 342 Debugging function to return current state of store 343 """ 344 345 current_store_items = [] 346 347 h: HEMS 348 349 if store == 'resource': 350 for h in self.store.items: 351 current_store_items.append(f"{h.callsign} ({h.category} online: {h.hems_resource_on_shift(hour, month)} {h.registration})") 352 else: 353 for h in self.serviceStore.items: 354 current_store_items.append(f"{h.callsign} ({h.category} online: {h.hems_resource_on_shift(hour, month)} {h.registration})") 355 356 return current_store_items 357 358 359 # def preferred_resource_available(self, pt: Patient) -> list[HEMS | None, str]: 360 361 # """ 362 # Check whether the preferred resource group is available. Returns a list with either the HEMS resource or None, and 363 # an indication as to whether the resource was available, or another resource in an established hierachy of 364 # availability can be allocated 365 # """ 366 367 # # Initialise object HEMS as a placeholder object 368 # hems: HEMS | None = None 369 # # Initialise variable 'preferred' to False 370 # preferred = 999 # This will be used to ensure that the most desireable resource 371 # # is allocated given that multiple matches may be found 372 # preferred_lookup = 0 # This will be used to code the resource allocation choice 373 374 # preferred_care_category = pt.hems_cc_or_ec 375 376 # self.debug(f"EC/CC resource with {preferred_care_category} and hour {pt.hour} and qtr {pt.qtr}") 377 378 # h: HEMS 379 # for h in self.store.items: 380 381 # # There is a hierachy of calls: 382 # # CC = H70 helicopter then car, then H71 helicopter then car then CC72 383 # # EC = H71 helicopter then car, then CC72, then H70 helicopter then car 384 # # If no resources then return None 385 386 # if not h.in_use and h.hems_resource_on_shift(pt.hour, pt.qtr): 387 # # self.debug(f"Checking whether to generate ad-hoc reason for {h} ({h.vehicle_type} - {h.callsign_group})") 388 389 # if ( # Skip this resource if any of the following are true: 390 # h.in_use or 391 # h.being_serviced or 392 # not h.hems_resource_on_shift(pt.hour, pt.qtr) or 393 # # Skip if crew is already in use 394 # h.callsign_group in self.active_callsign_groups or 395 # h.registration in self.active_registrations 396 # ): 397 # # self.debug(f"Skipping ad-hoc unavailability check for {h}") 398 # continue 399 400 # if h.vehicle_type == "car": 401 # ad_hoc_reason = "available" 402 # else: 403 # ad_hoc_reason = self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 404 405 # self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} ({pt.hems_cc_or_ec}) is: {ad_hoc_reason}") 406 407 # if ad_hoc_reason != "available": 408 # continue 409 410 # if h.category == preferred_care_category and h.vehicle_type == "helicopter" and not h.being_serviced: 411 # hems = h 412 # preferred = 1 # Top choice 413 # preferred_lookup = 1 414 # return [hems, self.resource_allocation_lookup(preferred_lookup)] 415 416 # elif h.category == preferred_care_category and not h.being_serviced: 417 # hems = h 418 # preferred = 2 # Second choice (correct care category, but not vehicle type) 419 # preferred_lookup = 2 420 421 # elif preferred_care_category == 'CC': 422 # if h.vehicle_type == 'helicopter' and h.category == 'EC' and not h.being_serviced: 423 # if preferred > 3: 424 # hems = h 425 # preferred = 3 # Third choice (EC helicopter) 426 # preferred_lookup = 3 427 428 # elif h.category == 'EC' and not h.being_serviced: 429 # if preferred > 4: 430 # hems = h 431 # preferred = 4 432 # preferred_lookup = 4 433 434 # elif preferred_care_category == 'EC': 435 # if h.vehicle_type == 'helicopter' and h.category == 'CC' and not h.being_serviced: 436 # if preferred > 3: 437 # hems = h 438 # preferred = 3 # CC helicopter available 439 # preferred_lookup = 5 440 441 # elif h.category == 'CC' and not h.being_serviced: 442 # hems = h 443 # preferred = 4 444 # preferred_lookup = 6 445 446 # self.debug(f"preferred lookup {preferred_lookup} and preferred = {preferred}") 447 448 # if preferred_lookup != 999: 449 # return [hems, self.resource_allocation_lookup(preferred_lookup)] 450 # else: 451 # return [None, self.resource_allocation_lookup(0)] 452 453 def preferred_resource_available(self, pt: Patient) -> list[HEMS | None, str]: 454 """ 455 Determine the best available HEMS resource for an EC/CC case based on the 456 patient's preferred care category (EC = Enhanced Care, CC = Critical Care), 457 vehicle type, and ad-hoc availability. Returns the chosen HEMS unit and 458 the reason for its selection. 459 460 Returns: 461 A list containing: 462 - The selected HEMS unit (or None if none available) 463 - A lookup code describing the allocation reason 464 """ 465 # Retrieve patient’s preferred care category 466 preferred_category = pt.hems_cc_or_ec 467 self.debug(f"EC/CC resource with {preferred_category} and hour {pt.hour} and qtr {pt.qtr}") 468 469 best_hems: HEMS | None = None # Best-matching HEMS unit found so far 470 best_priority = float('inf') # Lower values = better matches 471 best_lookup = ResourceAllocationReason.NONE_AVAILABLE # Reason for final allocation 472 473 for h in self.store.items: 474 # --- FILTER OUT UNAVAILABLE RESOURCES --- 475 if ( 476 h.in_use or # Already dispatched 477 h.being_serviced or # Currently under maintenance 478 not h.hems_resource_on_shift(pt.hour, pt.month) or # Not scheduled for shift now 479 h.callsign_group in self.active_callsign_groups or # Another unit from this group is active (so crew is engaged elsewhere) 480 h.registration in self.active_registrations # This specific unit is already dispatched 481 ): 482 continue # Move to the next HEMS unit 483 484 # Check ad-hoc reason 485 # For "car" units, assume always available. 486 # For helicopters, simulate availability using ad-hoc logic (e.g., weather, servicing). 487 reason = "available" if h.vehicle_type == "car" else self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 488 self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} ({pt.hems_cc_or_ec}) is: {reason}") 489 490 if reason != "available": 491 continue # Skip this unit if not usable 492 493 # Decide priority and reason 494 priority = None 495 lookup = None 496 497 # 1. Best case: resource category matches preferred care category *and* is a helicopter 498 if h.category == preferred_category and h.vehicle_type == "helicopter": 499 priority = 1 500 lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_HELI 501 502 # 2. Next best: resource category matches preferred care category, but is a car 503 elif h.category == preferred_category: 504 priority = 2 505 lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_CAR 506 507 # 3–4. Category mismatch fallback options: 508 # For a CC preference, fall back to EC providers if needed 509 elif preferred_category == "CC": 510 if h.category == "EC" and h.vehicle_type == "helicopter": 511 priority = 3 512 lookup = ResourceAllocationReason.CC_MATCH_EC_HELI 513 elif h.category == "EC": 514 priority = 4 515 lookup = ResourceAllocationReason.CC_MATCH_EC_CAR 516 517 # For an EC preference, fall back to CC providers if needed 518 elif preferred_category == "EC": 519 if h.category == "CC" and h.vehicle_type == "helicopter": 520 priority = 3 521 lookup = ResourceAllocationReason.EC_MATCH_CC_HELI 522 elif h.category == "CC": 523 priority = 4 524 lookup = ResourceAllocationReason.EC_MATCH_CC_CAR 525 526 # --- CHECK IF THIS IS THE BEST OPTION SO FAR --- 527 if priority is not None and priority < best_priority: 528 best_hems = h 529 best_priority = priority 530 best_lookup = lookup 531 532 # Immediate return if best possible match found (priority 1) 533 if priority == 1: 534 self.debug(f"Top priority match found: {best_lookup.name} ({best_lookup.value})") 535 return [best_hems, self.resource_allocation_lookup(best_lookup)] 536 537 # Final fallback: return the best match found (if any), or none with reason 538 self.debug(f"Selected best lookup: {best_lookup.name} ({best_lookup.value}) with priority = {best_priority}") 539 return [best_hems, self.resource_allocation_lookup(best_lookup)] 540 541 542 def allocate_resource(self, pt: Patient) -> Any | Event: 543 """ 544 Attempt to allocate a resource from the preferred group. 545 """ 546 resource_event: Event = self.env.event() 547 548 def process() -> Generator[Any, Any, None]: 549 self.debug(f"Allocating resource for {pt.id} and care cat {pt.hems_cc_or_ec}") 550 551 pref_res: list[HEMS | None, str] = self.preferred_resource_available(pt) 552 553 if pref_res[0] is None: 554 return resource_event.succeed([None, pref_res[1], None]) 555 556 primary = pref_res[0] 557 558 # Block if in-use by callsign group, registration, or callsign 559 if primary.callsign_group in self.active_callsign_groups: 560 self.debug(f"[BLOCKED] Callsign group {primary.callsign_group} in use") 561 return resource_event.succeed([None, pref_res[1], None]) 562 563 if primary.registration in self.active_registrations: 564 self.debug(f"[BLOCKED] Registration {primary.registration} in use") 565 return resource_event.succeed([None, pref_res[1], None]) 566 567 if primary.callsign in self.active_callsigns: 568 self.debug(f"[BLOCKED] Callsign {primary.callsign} already in use") 569 return resource_event.succeed([None, pref_res[1], None]) 570 571 self.active_callsign_groups.add(primary.callsign_group) 572 self.active_registrations.add(primary.registration) 573 self.active_callsigns.add(primary.callsign) 574 575 with self.store.get(lambda r: r == primary) as primary_request: 576 result = yield primary_request | self.env.timeout(0.1) 577 578 if primary_request in result: 579 primary.in_use = True 580 pt.hems_callsign_group = primary.callsign_group 581 pt.hems_vehicle_type = primary.vehicle_type 582 pt.hems_category = primary.category 583 584 # self.active_callsign_groups.add(primary.callsign_group) 585 # self.active_registrations.add(primary.registration) 586 # self.active_callsigns.add(primary.callsign) 587 588 # Try to get a secondary resource 589 with self.store.get(lambda r: 590 r != primary and 591 r.callsign_group == pt.hems_callsign_group and 592 r.category == pt.hems_category and 593 r.hems_resource_on_shift(pt.hour, pt.month) and 594 r.callsign_group not in self.active_callsign_groups and 595 r.registration not in self.active_registrations and 596 r.callsign not in self.active_callsigns 597 ) as secondary_request: 598 599 result2 = yield secondary_request | self.env.timeout(0.1) 600 secondary = None 601 602 if secondary_request in result2: 603 secondary = result2[secondary_request] 604 secondary.in_use = True 605 self.active_callsign_groups.add(secondary.callsign_group) 606 self.active_registrations.add(secondary.registration) 607 self.active_callsigns.add(secondary.callsign) 608 609 return resource_event.succeed([primary, pref_res[1], secondary]) 610 else: 611 # Roll back if unsuccessful 612 self.active_callsign_groups.discard(primary.callsign_group) 613 self.active_registrations.discard(primary.registration) 614 self.active_callsigns.discard(primary.callsign) 615 616 return resource_event.succeed([None, pref_res[1], None]) 617 618 self.env.process(process()) 619 return resource_event 620 621 622 def return_resource(self, resource: HEMS, secondary_resource: HEMS|None) -> None: 623 """ 624 Class to return HEMS class object back to the filestore. 625 """ 626 627 resource.in_use = False 628 self.active_callsign_groups.discard(resource.callsign_group) 629 self.active_registrations.discard(resource.registration) 630 self.active_callsigns.discard(resource.callsign) 631 self.store.put(resource) 632 self.debug(f"{resource.callsign} finished job") 633 634 if secondary_resource is not None: 635 secondary_resource.in_use = False 636 self.active_callsign_groups.discard(secondary_resource.callsign_group) 637 self.active_registrations.discard(secondary_resource.registration) 638 self.active_callsigns.discard(secondary_resource.callsign) 639 self.store.put(secondary_resource) 640 self.debug(f"{secondary_resource.callsign} free as {resource.callsign} finished job") 641 642 643 644 def years_between(self, start_date: datetime, end_date: datetime) -> list[int]: 645 """ 646 Function to return a list of years between given start and end date 647 """ 648 return list(range(start_date.year, end_date.year + 1)) 649 650 651 def do_ranges_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool: 652 """ 653 Function to determine whether two sets of datetimes overlap 654 """ 655 return max(start1, start2) <= min(end1, end2) 656 657 658 def is_during_school_holidays(self, start_date: datetime, end_date: datetime) -> bool: 659 """ 660 Function to calculate whether given start and end date time period falls within 661 a school holiday 662 """ 663 664 for index, row in self.school_holidays.iterrows(): 665 666 if self.do_ranges_overlap(pd.to_datetime(row['start_date']), pd.to_datetime(row['end_date']), start_date, end_date): 667 return True 668 669 return False 670 671 672 def is_other_resource_being_serviced(self, start_date, end_date, service_dates): 673 """ 674 Function to determine whether any resource is being services between a 675 given start and end date period. 676 """ 677 678 for sd in service_dates: 679 if self.do_ranges_overlap(sd['service_start_date'], sd['service_end_date'], start_date, end_date): 680 return True 681 682 return False 683 684 685 def find_next_service_date(self, last_service_date: datetime, interval_months: int, service_dates: list, service_duration: int) -> list[datetime]: 686 """ 687 Function to determine the next service date for a resource. The date is determine by 688 the servicing schedule for the resource, the preferred month of servicing, and to 689 avoid dates that fall in either school holidays or when other resources are being serviced. 690 """ 691 692 next_due_date = last_service_date + relativedelta(months = interval_months) # Approximate month length 693 end_date = next_due_date + timedelta(weeks = service_duration) 694 695 preferred_date = datetime(next_due_date.year, self.servicing_preferred_month , 2) 696 preferred_end_date = preferred_date + timedelta(weeks = service_duration) 697 698 if next_due_date.month > preferred_date.month: 699 preferred_date += relativedelta(years = 1) 700 701 # self.debug(f"Next due: {next_due_date} with end date {end_date} and preferred_date is {preferred_date} with pref end {preferred_end_date}") 702 703 # If preferred date is valid, use it 704 if preferred_date <= next_due_date and not self.is_during_school_holidays(preferred_date, preferred_end_date): 705 next_due_date = preferred_date 706 707 while True: 708 if self.is_during_school_holidays(next_due_date, end_date) or self.is_other_resource_being_serviced(next_due_date, end_date, service_dates): 709 next_due_date -= timedelta(days = 1) 710 end_date = next_due_date + timedelta(weeks = service_duration) 711 continue 712 else: 713 break 714 715 return [next_due_date, end_date] 716 717 718 # def preferred_regular_group_available(self, pt: Patient) -> list[HEMS | None, str]: 719 # """ 720 # Check availability for REG jobs while avoiding assigning two units from 721 # the same crew/callsign group. 722 # """ 723 # hems: HEMS | None = None 724 # preferred = 999 725 # preferred_lookup = 0 726 727 # preferred_group = pt.hems_pref_callsign_group 728 # preferred_vehicle_type = pt.hems_pref_vehicle_type 729 # helicopter_benefit = pt.hems_helicopter_benefit 730 731 # for h in self.store.items: 732 733 # if ( 734 # h.in_use or 735 # h.being_serviced or 736 # not h.hems_resource_on_shift(pt.hour, pt.qtr) or 737 # # Skip if crew is already in use 738 # h.callsign_group in self.active_callsign_groups or 739 # h.registration in self.active_registrations 740 # ): 741 # continue 742 743 744 # if h.vehicle_type == "car": 745 # ad_hoc_reason = "available" 746 # else: 747 # ad_hoc_reason = self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 748 749 # self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} (REG) is: {ad_hoc_reason}") 750 751 # if ad_hoc_reason != "available": 752 # continue 753 754 # # Helicopter benefit cases 755 # if helicopter_benefit == 'y': 756 # if h.vehicle_type == 'helicopter' and h.category == 'EC': 757 # hems = h 758 # preferred_lookup = 7 759 # break 760 # elif h.vehicle_type == 'helicopter': 761 # if preferred > 2: 762 # hems = h 763 # preferred = 2 764 # preferred_lookup = 8 765 766 # # Regular (non-helicopter) cases 767 # else: 768 # if (h.callsign_group == preferred_group 769 # and h.vehicle_type == preferred_vehicle_type): 770 # hems = h 771 # preferred_lookup = 9 772 # break 773 # elif h.callsign_group == preferred_group: 774 # if preferred > 4: 775 # hems = h 776 # preferred = 4 777 # preferred_lookup = 10 778 # else: 779 # if preferred > 5: 780 # hems = h 781 # preferred = 5 782 # preferred_lookup = 12 783 784 # return [hems, self.resource_allocation_lookup(preferred_lookup if hems else 0)] 785 786 def preferred_regular_group_available(self, pt: Patient) -> list[HEMS | None, str]: 787 """ 788 Determine the most suitable HEMS (Helicopter Emergency Medical Service) unit 789 for a REG (regular) job, ensuring that two units from the same crew/callsign 790 group are not simultaneously active. This method prioritizes matching the 791 patient's preferences and helicopter benefit where applicable. 792 793 Returns: 794 A list containing: 795 - The selected HEMS unit (or None if none available) 796 - A lookup code describing the allocation reason 797 """ 798 # Initialize selection variables 799 hems: HEMS | None = None # The selected resource 800 preferred = float("inf") # Lower numbers mean more preferred options (2, 4, 5 are used below) 801 preferred_lookup = ResourceAllocationReason.NONE_AVAILABLE # Reason code for selection 802 803 preferred_group = pt.hems_pref_callsign_group # Preferred crew group 804 preferred_vehicle_type = pt.hems_pref_vehicle_type # e.g., "car" or "helicopter" 805 helicopter_benefit = pt.hems_helicopter_benefit # "y" if helicopter has clinical benefit 806 # Iterate through all HEMS resources stored 807 for h in self.store.items: 808 if ( 809 h.in_use or # Already dispatched 810 h.being_serviced or # Currently under maintenance 811 not h.hems_resource_on_shift(pt.hour, pt.month) or # Not scheduled for shift now 812 h.callsign_group in self.active_callsign_groups or # Another unit from this group is active (so crew is engaged elsewhere) 813 h.registration in self.active_registrations # This specific unit is already dispatched 814 ): 815 continue # Move to the next HEMS unit 816 817 # Check ad hoc availability 818 # For "car" units, assume always available. 819 # For helicopters, simulate availability using ad-hoc logic (e.g., weather, servicing). 820 reason = "available" if h.vehicle_type == "car" else self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 821 self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} (REG) is: {reason}") 822 823 if reason != "available": 824 continue # Skip this unit if not usable 825 826 # --- HELICOPTER BENEFIT CASE --- 827 # P3 = Helicopter patient 828 # Resources allocated in following order: 829 # IF H70 available = SEND 830 # ELSE H71 available = SEND 831 832 # Assume 30% chance of knowing in advance that it's going to be a heli benefit case 833 # when choosing to send a resource (and if you know that, don't fall back to 834 # sending a car) 835 if helicopter_benefit == "y" and self.utilityClass.rngs["know_heli_benefit"].uniform(0, 1) <= 0.5: 836 # Priority 1: CC-category helicopter (assumed most beneficial) 837 if h.vehicle_type == "helicopter" and h.category == "CC": 838 hems = h 839 preferred_lookup = ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_CC_HELI 840 break 841 # Priority 2: Any helicopter (less preferred than CC, hence priority = 2) 842 elif h.vehicle_type == "helicopter" and preferred > 2: 843 hems = h 844 preferred = 2 845 preferred_lookup = ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_EC_HELI 846 847 848 # If no EC or CC helicopters are available, then: 849 # - hems remains None 850 # - preferred_lookup remains at its initial value (ResourceAllocationReason.NONE_AVAILABLE) 851 # - The function exits the loop without assigning a resource. 852 853 # --- REGULAR JOB WITH NO SIMULATED HELICOPTER BENEFIT --- 854 else: 855 # Best match: matching both preferred callsign group and vehicle type 856 if h.callsign_group == preferred_group and h.vehicle_type == preferred_vehicle_type: 857 hems = h 858 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE 859 break 860 # Next best: send a helicopter 861 elif h.vehicle_type == "helicopter" and preferred > 3: 862 hems = h 863 preferred = 3 864 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_VEHICLE 865 # Next best: match only on preferred callsign group 866 elif h.callsign_group == preferred_group and preferred > 4: 867 hems = h 868 preferred = 4 869 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP 870 # Fallback: any available resource 871 elif preferred > 5: 872 hems = h 873 preferred = 5 874 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_ANY 875 876 # Return the best found HEMS resource and reason for selection 877 self.debug(f"Selected REG (heli benefit = {helicopter_benefit}) lookup: {preferred_lookup.name} ({preferred_lookup.value})") 878 return [hems, self.resource_allocation_lookup(preferred_lookup if hems else ResourceAllocationReason.NONE_AVAILABLE)] 879 880 881 def allocate_regular_resource(self, pt: Patient) -> Any | Event: 882 """ 883 Attempt to allocate a resource from the preferred group (REG jobs). 884 """ 885 resource_event: Event = self.env.event() 886 887 def process() -> Generator[Any, Any, None]: 888 # if pt.hems_pref_vehicle_type == "Other": 889 # pref_res = [None, self.resource_allocation_lookup(11)] 890 # else: 891 pref_res = self.preferred_regular_group_available(pt) 892 893 if pref_res[0] is None: 894 return resource_event.succeed([None, pref_res[1], None]) 895 896 primary = pref_res[0] 897 898 # Block if in-use by callsign group, registration, or callsign 899 if primary.callsign_group in self.active_callsign_groups: 900 self.debug(f"[BLOCKED] Regular Callsign group {primary.callsign_group} in use") 901 return resource_event.succeed([None, pref_res[1], None]) 902 903 if primary.registration in self.active_registrations: 904 self.debug(f"[BLOCKED] Regular Registration {primary.registration} in use") 905 return resource_event.succeed([None, pref_res[1], None]) 906 907 if primary.callsign in self.active_callsigns: 908 self.debug(f"[BLOCKED] Regular Callsign {primary.callsign} already in use") 909 return resource_event.succeed([None, pref_res[1], None]) 910 911 self.active_callsign_groups.add(primary.callsign_group) 912 self.active_registrations.add(primary.registration) 913 self.active_callsigns.add(primary.callsign) 914 915 with self.store.get(lambda r: r == primary) as primary_request: 916 result = yield primary_request | self.env.timeout(0.1) 917 918 if primary_request in result: 919 primary.in_use = True 920 pt.hems_callsign_group = primary.callsign_group 921 pt.hems_vehicle_type = primary.vehicle_type 922 pt.hems_category = primary.category 923 924 # self.active_callsign_groups.add(primary.callsign_group) 925 # self.active_registrations.add(primary.registration) 926 # self.active_callsigns.add(primary.callsign) 927 928 # Try to get a secondary resource 929 with self.store.get(lambda r: 930 r != primary and 931 r.callsign_group == pt.hems_callsign_group and 932 r.category == pt.hems_category and 933 r.hems_resource_on_shift(pt.hour, pt.month) and 934 r.callsign_group not in self.active_callsign_groups and 935 r.registration not in self.active_registrations and 936 r.callsign not in self.active_callsigns 937 ) as secondary_request: 938 939 result2 = yield secondary_request | self.env.timeout(0.1) 940 secondary = None 941 942 if secondary_request in result2: 943 secondary = result2[secondary_request] 944 secondary.in_use = True 945 self.active_callsign_groups.add(secondary.callsign_group) 946 self.active_registrations.add(secondary.registration) 947 self.active_callsigns.add(secondary.callsign) 948 949 return resource_event.succeed([primary, pref_res[1], secondary]) 950 951 else: 952 # Roll back if unsuccessful 953 self.active_callsign_groups.discard(primary.callsign_group) 954 self.active_registrations.discard(primary.registration) 955 self.active_callsigns.discard(primary.callsign) 956 957 return resource_event.succeed([None, pref_res[1], None]) 958 959 self.env.process(process()) 960 return resource_event
The HEMS Availability class
This class is a filter store which can provide HEMS resources based on the time of day and servicing schedule
41 def __init__(self, env, sim_start_date, sim_duration, utility: Utils, servicing_overlap_allowed = False, 42 servicing_buffer_weeks = 4, servicing_preferred_month = 1, 43 print_debug_messages = False, master_seed=SeedSequence(42)): 44 45 self.LOOKUP_LIST = [ 46 "No HEMS resource available", #0 47 "Preferred HEMS care category and vehicle type match", # 1 48 "Preferred HEMS care category match but not vehicle type", # 2 49 "HEMS CC case EC helicopter available", # 3 50 "HEMS CC case EC car available", # 4 51 "HEMS EC case CC helicopter available", # 5 52 "HEMS EC case CC car available", # 6 53 "HEMS REG helicopter case EC helicopter available", 54 "HEMS REG helicopter case CC helicopter available", 55 "HEMS REG case no helicopter benefit preferred group and vehicle type allocated", # 9 56 "HEMS REG case no helicopter benefit preferred group allocated", # 10 57 "No HEMS resource available (pref vehicle type = 'Other')", # 11 58 "HEMS REG case no helicopter benefit first free resource allocated", # 12 59 "HEMS REG case no helicopter benefit free helicopter allocated" #13 60 ] 61 62 self.env = env 63 self.print_debug_messages = print_debug_messages 64 self.master_seed = master_seed 65 self.utilityClass = utility 66 67 # Adding options to set servicing parameters. 68 self.servicing_overlap_allowed = servicing_overlap_allowed 69 self.serviing_buffer_weeks = servicing_buffer_weeks 70 self.servicing_preferred_month = servicing_preferred_month 71 self.sim_start_date = sim_start_date 72 73 self.debug(f"Sim start date {self.sim_start_date}") 74 # For belts and braces, add an additional year to 75 # calculate the service schedules since service dates can be walked back to the 76 # previous year 77 self.sim_end_date = sim_start_date + timedelta(minutes=sim_duration + (1*365*24*60)) 78 79 # School holidays 80 self.school_holidays = pd.read_csv('actual_data/school_holidays.csv') 81 82 self.HEMS_resources_list = [] 83 84 self.active_callsign_groups = set() # Prevents same crew being used twice...hopefully... 85 self.active_registrations = set() # Prevents same vehicle being used twice 86 self.active_callsigns = set() 87 88 # Create a store for HEMS resources 89 self.store = FilterStore(env) 90 91 self.serviceStore = FilterStore(env) 92 93 # Prepare HEMS resources for ingesting into store 94 self.prep_HEMS_resources() 95 96 # Populate the store with HEMS resources 97 self.populate_store() 98 99 # Daily servicing check (in case sim starts during a service) 100 [dow, hod, weekday, month, qtr, current_dt] = self.utilityClass.date_time_of_call(self.sim_start_date, self.env.now) 101 102 self.daily_servicing_check(current_dt, hod, month)
109 def daily_servicing_check(self, current_dt: datetime, hour: int, month: int): 110 """ 111 Function to iterate through the store and trigger the service check 112 function in the HEMS class 113 """ 114 h: HEMS 115 116 self.debug('------ DAILY SERVICING CHECK -------') 117 118 GDAAS_service = False 119 120 all_resources = self.serviceStore.items + self.store.items 121 for h in all_resources: 122 if h.registration == 'g-daas': 123 GDAAS_service = h.unavailable_due_to_service(current_dt) 124 break 125 126 self.debug(f"GDAAS_service is {GDAAS_service}") 127 128 # --- Return from serviceStore to store --- 129 to_return = [ 130 (s.category, s.registration) 131 for s in self.serviceStore.items 132 if not s.service_check(current_dt, GDAAS_service) # Note the NOT here! 133 ] 134 135 # Attempted fix for gap after return from H70 duties 136 for h in self.store.items: 137 if h.registration == 'g-daan' and not GDAAS_service: 138 h.callsign_group = 71 139 h.callsign = 'H71' 140 141 if to_return: 142 self.debug("Service store has items to return") 143 144 for category, registration in to_return: 145 s = yield self.serviceStore.get( 146 lambda item: item.category == category and item.registration == registration 147 ) 148 yield self.store.put(s) 149 self.debug(f"Returned [{s.category} / {s.registration}] from service to store") 150 151 # --- Send from store to serviceStore --- 152 to_service = [ 153 (h.category, h.registration) 154 for h in self.store.items 155 if h.service_check(current_dt, GDAAS_service) 156 ] 157 158 for category, registration in to_service: 159 self.debug("****************") 160 self.debug(f"HEMS [{category} / {registration}] being serviced, removing from store") 161 162 h = yield self.store.get( 163 lambda item: item.category == category and item.registration == registration 164 ) 165 166 self.debug(f"HEMS [{h.category} / {h.registration}] successfully removed from store") 167 yield self.serviceStore.put(h) 168 self.debug(f"HEMS [{h.category} / {h.registration}] moved to service store") 169 self.debug("***********") 170 171 self.debug(self.current_store_status(hour, month)) 172 self.debug(self.current_store_status(hour, month, 'service')) 173 174 [dow, hod, weekday, month, qtr, current_dt] = self.utilityClass.date_time_of_call(self.sim_start_date, self.env.now) 175 for h in self.store.items: 176 if h.registration == 'g-daan': 177 self.debug(f"[{self.env.now}] g-daan status: in_use={h.in_use}, callsign={h.callsign}, group={h.callsign_group}, on_shift={h.hems_resource_on_shift(hod, month)}") 178 179 self.debug('------ END OF DAILY SERVICING CHECK -------')
Function to iterate through the store and trigger the service check function in the HEMS class
182 def prep_HEMS_resources(self) -> None: 183 """ 184 This function ingests HEMS resource data from a user-supplied CSV file 185 and populates a list of HEMS class objects. The key activity here is 186 the calculation of service schedules for each HEMS object, taking into account a 187 user-specified preferred month of servicing, service duration, and a buffer period 188 following a service to allow for over-runs and school holidays 189 190 """ 191 192 schedule = [] 193 service_dates = [] 194 195 196 # Calculate service schedules for each resource 197 198 SERVICING_SCHEDULE = pd.read_csv('actual_data/service_schedules_by_model.csv') 199 SERVICE_HISTORY = pd.read_csv('actual_data/service_history.csv', na_values=0) 200 CALLSIGN_REGISTRATION = pd.read_csv('actual_data/callsign_registration_lookup.csv') 201 202 SERVICING_SCHEDULE = SERVICING_SCHEDULE.merge( 203 CALLSIGN_REGISTRATION, 204 how="right", 205 on="model" 206 ) 207 208 SERVICING_SCHEDULE = SERVICING_SCHEDULE.merge( 209 SERVICE_HISTORY, 210 how="left", 211 on="registration" 212 ) 213 214 self.debug(f"prep_hems_resources: schedule {SERVICING_SCHEDULE}") 215 216 for index, row in SERVICING_SCHEDULE.iterrows(): 217 #self.debug(row) 218 current_resource_service_dates = [] 219 # Check if service date provided 220 if not pd.isna(row['last_service']): 221 #self.debug(f"Checking {row['registration']} with previous service date of {row['last_service']}") 222 last_service = datetime.strptime(row['last_service'], "%Y-%m-%d") 223 service_date = last_service 224 225 while last_service < self.sim_end_date: 226 227 end_date = last_service + \ 228 timedelta(weeks = int(row['service_duration_weeks'])) + \ 229 timedelta(weeks=self.serviing_buffer_weeks) 230 231 service_date, end_date = self.find_next_service_date( 232 last_service, 233 row["service_schedule_months"], 234 service_dates, 235 row['service_duration_weeks'] 236 ) 237 238 schedule.append((row['registration'], service_date, end_date)) 239 #self.debug(service_date) 240 service_dates.append({'service_start_date': service_date, 'service_end_date': end_date}) 241 242 current_resource_service_dates.append({'service_start_date': service_date, 'service_end_date': end_date}) 243 #self.debug(service_dates) 244 #self.debug(current_resource_service_dates) 245 last_service = service_date 246 else: 247 schedule.append((row['registration'], None, None)) 248 #self.debug(schedule) 249 250 service_df = pd.DataFrame(schedule, columns=["registration", "service_start_date", "service_end_date"]) 251 252 service_df.to_csv("data/service_dates.csv", index=False) 253 254 # Append those to the HEMS resource. 255 256 HEMS_RESOURCES = ( 257 pd.read_csv("actual_data/HEMS_ROTA.csv") 258 # Add model and servicing rules 259 .merge( 260 SERVICING_SCHEDULE, 261 on=["callsign", "vehicle_type"], 262 how="left" 263 ) 264 ) 265 266 for index, row in HEMS_RESOURCES.iterrows(): 267 268 s = service_df[service_df['registration'] == row['registration']] 269 #self.debug(s) 270 271 # Create new HEMS resource and add to HEMS_resource_list 272 #pd.DataFrame(columns=['year', 'service_start_date', 'service_end_date']) 273 hems = HEMS( 274 callsign = row['callsign'], 275 callsign_group = row['callsign_group'], 276 vehicle_type = row['vehicle_type'], 277 category = row['category'], 278 registration = row['registration'], 279 summer_start = row['summer_start'], 280 winter_start = row['winter_start'], 281 summer_end = row['summer_end'], 282 winter_end = row['winter_end'], 283 servicing_schedule = s, 284 resource_id = row['registration'] 285 ) 286 287 self.HEMS_resources_list.append(hems) 288 289 #self.debug(self.HEMS_resources_list)
This function ingests HEMS resource data from a user-supplied CSV file and populates a list of HEMS class objects. The key activity here is the calculation of service schedules for each HEMS object, taking into account a user-specified preferred month of servicing, service duration, and a buffer period following a service to allow for over-runs and school holidays
292 def populate_store(self): 293 """ 294 Function to populate the filestore with HEMS class objects 295 contained in a class list 296 """ 297 298 h: HEMS 299 for h in self.HEMS_resources_list: 300 self.debug(f"Populating resource store: HEMS({h.callsign})") 301 self.debug(h.servicing_schedule) 302 self.store.put(h)
Function to populate the filestore with HEMS class objects contained in a class list
305 def add_hems(self): 306 """ 307 Future function to allow for adding HEMS resources. 308 We might not use this (we could just amend the HEMS_ROTA dataframe, for example) 309 but might be useful for 'what if' simulations 310 """ 311 pass
Future function to allow for adding HEMS resources. We might not use this (we could just amend the HEMS_ROTA dataframe, for example) but might be useful for 'what if' simulations
340 def current_store_status(self, hour, month, store = 'resource') -> list[str]: 341 """ 342 Debugging function to return current state of store 343 """ 344 345 current_store_items = [] 346 347 h: HEMS 348 349 if store == 'resource': 350 for h in self.store.items: 351 current_store_items.append(f"{h.callsign} ({h.category} online: {h.hems_resource_on_shift(hour, month)} {h.registration})") 352 else: 353 for h in self.serviceStore.items: 354 current_store_items.append(f"{h.callsign} ({h.category} online: {h.hems_resource_on_shift(hour, month)} {h.registration})") 355 356 return current_store_items
Debugging function to return current state of store
453 def preferred_resource_available(self, pt: Patient) -> list[HEMS | None, str]: 454 """ 455 Determine the best available HEMS resource for an EC/CC case based on the 456 patient's preferred care category (EC = Enhanced Care, CC = Critical Care), 457 vehicle type, and ad-hoc availability. Returns the chosen HEMS unit and 458 the reason for its selection. 459 460 Returns: 461 A list containing: 462 - The selected HEMS unit (or None if none available) 463 - A lookup code describing the allocation reason 464 """ 465 # Retrieve patient’s preferred care category 466 preferred_category = pt.hems_cc_or_ec 467 self.debug(f"EC/CC resource with {preferred_category} and hour {pt.hour} and qtr {pt.qtr}") 468 469 best_hems: HEMS | None = None # Best-matching HEMS unit found so far 470 best_priority = float('inf') # Lower values = better matches 471 best_lookup = ResourceAllocationReason.NONE_AVAILABLE # Reason for final allocation 472 473 for h in self.store.items: 474 # --- FILTER OUT UNAVAILABLE RESOURCES --- 475 if ( 476 h.in_use or # Already dispatched 477 h.being_serviced or # Currently under maintenance 478 not h.hems_resource_on_shift(pt.hour, pt.month) or # Not scheduled for shift now 479 h.callsign_group in self.active_callsign_groups or # Another unit from this group is active (so crew is engaged elsewhere) 480 h.registration in self.active_registrations # This specific unit is already dispatched 481 ): 482 continue # Move to the next HEMS unit 483 484 # Check ad-hoc reason 485 # For "car" units, assume always available. 486 # For helicopters, simulate availability using ad-hoc logic (e.g., weather, servicing). 487 reason = "available" if h.vehicle_type == "car" else self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 488 self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} ({pt.hems_cc_or_ec}) is: {reason}") 489 490 if reason != "available": 491 continue # Skip this unit if not usable 492 493 # Decide priority and reason 494 priority = None 495 lookup = None 496 497 # 1. Best case: resource category matches preferred care category *and* is a helicopter 498 if h.category == preferred_category and h.vehicle_type == "helicopter": 499 priority = 1 500 lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_HELI 501 502 # 2. Next best: resource category matches preferred care category, but is a car 503 elif h.category == preferred_category: 504 priority = 2 505 lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_CAR 506 507 # 3–4. Category mismatch fallback options: 508 # For a CC preference, fall back to EC providers if needed 509 elif preferred_category == "CC": 510 if h.category == "EC" and h.vehicle_type == "helicopter": 511 priority = 3 512 lookup = ResourceAllocationReason.CC_MATCH_EC_HELI 513 elif h.category == "EC": 514 priority = 4 515 lookup = ResourceAllocationReason.CC_MATCH_EC_CAR 516 517 # For an EC preference, fall back to CC providers if needed 518 elif preferred_category == "EC": 519 if h.category == "CC" and h.vehicle_type == "helicopter": 520 priority = 3 521 lookup = ResourceAllocationReason.EC_MATCH_CC_HELI 522 elif h.category == "CC": 523 priority = 4 524 lookup = ResourceAllocationReason.EC_MATCH_CC_CAR 525 526 # --- CHECK IF THIS IS THE BEST OPTION SO FAR --- 527 if priority is not None and priority < best_priority: 528 best_hems = h 529 best_priority = priority 530 best_lookup = lookup 531 532 # Immediate return if best possible match found (priority 1) 533 if priority == 1: 534 self.debug(f"Top priority match found: {best_lookup.name} ({best_lookup.value})") 535 return [best_hems, self.resource_allocation_lookup(best_lookup)] 536 537 # Final fallback: return the best match found (if any), or none with reason 538 self.debug(f"Selected best lookup: {best_lookup.name} ({best_lookup.value}) with priority = {best_priority}") 539 return [best_hems, self.resource_allocation_lookup(best_lookup)]
Determine the best available HEMS resource for an EC/CC case based on the patient's preferred care category (EC = Enhanced Care, CC = Critical Care), vehicle type, and ad-hoc availability. Returns the chosen HEMS unit and the reason for its selection.
Returns: A list containing: - The selected HEMS unit (or None if none available) - A lookup code describing the allocation reason
542 def allocate_resource(self, pt: Patient) -> Any | Event: 543 """ 544 Attempt to allocate a resource from the preferred group. 545 """ 546 resource_event: Event = self.env.event() 547 548 def process() -> Generator[Any, Any, None]: 549 self.debug(f"Allocating resource for {pt.id} and care cat {pt.hems_cc_or_ec}") 550 551 pref_res: list[HEMS | None, str] = self.preferred_resource_available(pt) 552 553 if pref_res[0] is None: 554 return resource_event.succeed([None, pref_res[1], None]) 555 556 primary = pref_res[0] 557 558 # Block if in-use by callsign group, registration, or callsign 559 if primary.callsign_group in self.active_callsign_groups: 560 self.debug(f"[BLOCKED] Callsign group {primary.callsign_group} in use") 561 return resource_event.succeed([None, pref_res[1], None]) 562 563 if primary.registration in self.active_registrations: 564 self.debug(f"[BLOCKED] Registration {primary.registration} in use") 565 return resource_event.succeed([None, pref_res[1], None]) 566 567 if primary.callsign in self.active_callsigns: 568 self.debug(f"[BLOCKED] Callsign {primary.callsign} already in use") 569 return resource_event.succeed([None, pref_res[1], None]) 570 571 self.active_callsign_groups.add(primary.callsign_group) 572 self.active_registrations.add(primary.registration) 573 self.active_callsigns.add(primary.callsign) 574 575 with self.store.get(lambda r: r == primary) as primary_request: 576 result = yield primary_request | self.env.timeout(0.1) 577 578 if primary_request in result: 579 primary.in_use = True 580 pt.hems_callsign_group = primary.callsign_group 581 pt.hems_vehicle_type = primary.vehicle_type 582 pt.hems_category = primary.category 583 584 # self.active_callsign_groups.add(primary.callsign_group) 585 # self.active_registrations.add(primary.registration) 586 # self.active_callsigns.add(primary.callsign) 587 588 # Try to get a secondary resource 589 with self.store.get(lambda r: 590 r != primary and 591 r.callsign_group == pt.hems_callsign_group and 592 r.category == pt.hems_category and 593 r.hems_resource_on_shift(pt.hour, pt.month) and 594 r.callsign_group not in self.active_callsign_groups and 595 r.registration not in self.active_registrations and 596 r.callsign not in self.active_callsigns 597 ) as secondary_request: 598 599 result2 = yield secondary_request | self.env.timeout(0.1) 600 secondary = None 601 602 if secondary_request in result2: 603 secondary = result2[secondary_request] 604 secondary.in_use = True 605 self.active_callsign_groups.add(secondary.callsign_group) 606 self.active_registrations.add(secondary.registration) 607 self.active_callsigns.add(secondary.callsign) 608 609 return resource_event.succeed([primary, pref_res[1], secondary]) 610 else: 611 # Roll back if unsuccessful 612 self.active_callsign_groups.discard(primary.callsign_group) 613 self.active_registrations.discard(primary.registration) 614 self.active_callsigns.discard(primary.callsign) 615 616 return resource_event.succeed([None, pref_res[1], None]) 617 618 self.env.process(process()) 619 return resource_event
Attempt to allocate a resource from the preferred group.
622 def return_resource(self, resource: HEMS, secondary_resource: HEMS|None) -> None: 623 """ 624 Class to return HEMS class object back to the filestore. 625 """ 626 627 resource.in_use = False 628 self.active_callsign_groups.discard(resource.callsign_group) 629 self.active_registrations.discard(resource.registration) 630 self.active_callsigns.discard(resource.callsign) 631 self.store.put(resource) 632 self.debug(f"{resource.callsign} finished job") 633 634 if secondary_resource is not None: 635 secondary_resource.in_use = False 636 self.active_callsign_groups.discard(secondary_resource.callsign_group) 637 self.active_registrations.discard(secondary_resource.registration) 638 self.active_callsigns.discard(secondary_resource.callsign) 639 self.store.put(secondary_resource) 640 self.debug(f"{secondary_resource.callsign} free as {resource.callsign} finished job")
Class to return HEMS class object back to the filestore.
644 def years_between(self, start_date: datetime, end_date: datetime) -> list[int]: 645 """ 646 Function to return a list of years between given start and end date 647 """ 648 return list(range(start_date.year, end_date.year + 1))
Function to return a list of years between given start and end date
651 def do_ranges_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool: 652 """ 653 Function to determine whether two sets of datetimes overlap 654 """ 655 return max(start1, start2) <= min(end1, end2)
Function to determine whether two sets of datetimes overlap
658 def is_during_school_holidays(self, start_date: datetime, end_date: datetime) -> bool: 659 """ 660 Function to calculate whether given start and end date time period falls within 661 a school holiday 662 """ 663 664 for index, row in self.school_holidays.iterrows(): 665 666 if self.do_ranges_overlap(pd.to_datetime(row['start_date']), pd.to_datetime(row['end_date']), start_date, end_date): 667 return True 668 669 return False
Function to calculate whether given start and end date time period falls within a school holiday
672 def is_other_resource_being_serviced(self, start_date, end_date, service_dates): 673 """ 674 Function to determine whether any resource is being services between a 675 given start and end date period. 676 """ 677 678 for sd in service_dates: 679 if self.do_ranges_overlap(sd['service_start_date'], sd['service_end_date'], start_date, end_date): 680 return True 681 682 return False
Function to determine whether any resource is being services between a given start and end date period.
685 def find_next_service_date(self, last_service_date: datetime, interval_months: int, service_dates: list, service_duration: int) -> list[datetime]: 686 """ 687 Function to determine the next service date for a resource. The date is determine by 688 the servicing schedule for the resource, the preferred month of servicing, and to 689 avoid dates that fall in either school holidays or when other resources are being serviced. 690 """ 691 692 next_due_date = last_service_date + relativedelta(months = interval_months) # Approximate month length 693 end_date = next_due_date + timedelta(weeks = service_duration) 694 695 preferred_date = datetime(next_due_date.year, self.servicing_preferred_month , 2) 696 preferred_end_date = preferred_date + timedelta(weeks = service_duration) 697 698 if next_due_date.month > preferred_date.month: 699 preferred_date += relativedelta(years = 1) 700 701 # self.debug(f"Next due: {next_due_date} with end date {end_date} and preferred_date is {preferred_date} with pref end {preferred_end_date}") 702 703 # If preferred date is valid, use it 704 if preferred_date <= next_due_date and not self.is_during_school_holidays(preferred_date, preferred_end_date): 705 next_due_date = preferred_date 706 707 while True: 708 if self.is_during_school_holidays(next_due_date, end_date) or self.is_other_resource_being_serviced(next_due_date, end_date, service_dates): 709 next_due_date -= timedelta(days = 1) 710 end_date = next_due_date + timedelta(weeks = service_duration) 711 continue 712 else: 713 break 714 715 return [next_due_date, end_date]
Function to determine the next service date for a resource. The date is determine by the servicing schedule for the resource, the preferred month of servicing, and to avoid dates that fall in either school holidays or when other resources are being serviced.
786 def preferred_regular_group_available(self, pt: Patient) -> list[HEMS | None, str]: 787 """ 788 Determine the most suitable HEMS (Helicopter Emergency Medical Service) unit 789 for a REG (regular) job, ensuring that two units from the same crew/callsign 790 group are not simultaneously active. This method prioritizes matching the 791 patient's preferences and helicopter benefit where applicable. 792 793 Returns: 794 A list containing: 795 - The selected HEMS unit (or None if none available) 796 - A lookup code describing the allocation reason 797 """ 798 # Initialize selection variables 799 hems: HEMS | None = None # The selected resource 800 preferred = float("inf") # Lower numbers mean more preferred options (2, 4, 5 are used below) 801 preferred_lookup = ResourceAllocationReason.NONE_AVAILABLE # Reason code for selection 802 803 preferred_group = pt.hems_pref_callsign_group # Preferred crew group 804 preferred_vehicle_type = pt.hems_pref_vehicle_type # e.g., "car" or "helicopter" 805 helicopter_benefit = pt.hems_helicopter_benefit # "y" if helicopter has clinical benefit 806 # Iterate through all HEMS resources stored 807 for h in self.store.items: 808 if ( 809 h.in_use or # Already dispatched 810 h.being_serviced or # Currently under maintenance 811 not h.hems_resource_on_shift(pt.hour, pt.month) or # Not scheduled for shift now 812 h.callsign_group in self.active_callsign_groups or # Another unit from this group is active (so crew is engaged elsewhere) 813 h.registration in self.active_registrations # This specific unit is already dispatched 814 ): 815 continue # Move to the next HEMS unit 816 817 # Check ad hoc availability 818 # For "car" units, assume always available. 819 # For helicopters, simulate availability using ad-hoc logic (e.g., weather, servicing). 820 reason = "available" if h.vehicle_type == "car" else self.utilityClass.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) 821 self.debug(f"({h.callsign}) Sampled reason for patient {pt.id} (REG) is: {reason}") 822 823 if reason != "available": 824 continue # Skip this unit if not usable 825 826 # --- HELICOPTER BENEFIT CASE --- 827 # P3 = Helicopter patient 828 # Resources allocated in following order: 829 # IF H70 available = SEND 830 # ELSE H71 available = SEND 831 832 # Assume 30% chance of knowing in advance that it's going to be a heli benefit case 833 # when choosing to send a resource (and if you know that, don't fall back to 834 # sending a car) 835 if helicopter_benefit == "y" and self.utilityClass.rngs["know_heli_benefit"].uniform(0, 1) <= 0.5: 836 # Priority 1: CC-category helicopter (assumed most beneficial) 837 if h.vehicle_type == "helicopter" and h.category == "CC": 838 hems = h 839 preferred_lookup = ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_CC_HELI 840 break 841 # Priority 2: Any helicopter (less preferred than CC, hence priority = 2) 842 elif h.vehicle_type == "helicopter" and preferred > 2: 843 hems = h 844 preferred = 2 845 preferred_lookup = ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_EC_HELI 846 847 848 # If no EC or CC helicopters are available, then: 849 # - hems remains None 850 # - preferred_lookup remains at its initial value (ResourceAllocationReason.NONE_AVAILABLE) 851 # - The function exits the loop without assigning a resource. 852 853 # --- REGULAR JOB WITH NO SIMULATED HELICOPTER BENEFIT --- 854 else: 855 # Best match: matching both preferred callsign group and vehicle type 856 if h.callsign_group == preferred_group and h.vehicle_type == preferred_vehicle_type: 857 hems = h 858 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE 859 break 860 # Next best: send a helicopter 861 elif h.vehicle_type == "helicopter" and preferred > 3: 862 hems = h 863 preferred = 3 864 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_VEHICLE 865 # Next best: match only on preferred callsign group 866 elif h.callsign_group == preferred_group and preferred > 4: 867 hems = h 868 preferred = 4 869 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP 870 # Fallback: any available resource 871 elif preferred > 5: 872 hems = h 873 preferred = 5 874 preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_ANY 875 876 # Return the best found HEMS resource and reason for selection 877 self.debug(f"Selected REG (heli benefit = {helicopter_benefit}) lookup: {preferred_lookup.name} ({preferred_lookup.value})") 878 return [hems, self.resource_allocation_lookup(preferred_lookup if hems else ResourceAllocationReason.NONE_AVAILABLE)]
Determine the most suitable HEMS (Helicopter Emergency Medical Service) unit for a REG (regular) job, ensuring that two units from the same crew/callsign group are not simultaneously active. This method prioritizes matching the patient's preferences and helicopter benefit where applicable.
Returns: A list containing: - The selected HEMS unit (or None if none available) - A lookup code describing the allocation reason
881 def allocate_regular_resource(self, pt: Patient) -> Any | Event: 882 """ 883 Attempt to allocate a resource from the preferred group (REG jobs). 884 """ 885 resource_event: Event = self.env.event() 886 887 def process() -> Generator[Any, Any, None]: 888 # if pt.hems_pref_vehicle_type == "Other": 889 # pref_res = [None, self.resource_allocation_lookup(11)] 890 # else: 891 pref_res = self.preferred_regular_group_available(pt) 892 893 if pref_res[0] is None: 894 return resource_event.succeed([None, pref_res[1], None]) 895 896 primary = pref_res[0] 897 898 # Block if in-use by callsign group, registration, or callsign 899 if primary.callsign_group in self.active_callsign_groups: 900 self.debug(f"[BLOCKED] Regular Callsign group {primary.callsign_group} in use") 901 return resource_event.succeed([None, pref_res[1], None]) 902 903 if primary.registration in self.active_registrations: 904 self.debug(f"[BLOCKED] Regular Registration {primary.registration} in use") 905 return resource_event.succeed([None, pref_res[1], None]) 906 907 if primary.callsign in self.active_callsigns: 908 self.debug(f"[BLOCKED] Regular Callsign {primary.callsign} already in use") 909 return resource_event.succeed([None, pref_res[1], None]) 910 911 self.active_callsign_groups.add(primary.callsign_group) 912 self.active_registrations.add(primary.registration) 913 self.active_callsigns.add(primary.callsign) 914 915 with self.store.get(lambda r: r == primary) as primary_request: 916 result = yield primary_request | self.env.timeout(0.1) 917 918 if primary_request in result: 919 primary.in_use = True 920 pt.hems_callsign_group = primary.callsign_group 921 pt.hems_vehicle_type = primary.vehicle_type 922 pt.hems_category = primary.category 923 924 # self.active_callsign_groups.add(primary.callsign_group) 925 # self.active_registrations.add(primary.registration) 926 # self.active_callsigns.add(primary.callsign) 927 928 # Try to get a secondary resource 929 with self.store.get(lambda r: 930 r != primary and 931 r.callsign_group == pt.hems_callsign_group and 932 r.category == pt.hems_category and 933 r.hems_resource_on_shift(pt.hour, pt.month) and 934 r.callsign_group not in self.active_callsign_groups and 935 r.registration not in self.active_registrations and 936 r.callsign not in self.active_callsigns 937 ) as secondary_request: 938 939 result2 = yield secondary_request | self.env.timeout(0.1) 940 secondary = None 941 942 if secondary_request in result2: 943 secondary = result2[secondary_request] 944 secondary.in_use = True 945 self.active_callsign_groups.add(secondary.callsign_group) 946 self.active_registrations.add(secondary.registration) 947 self.active_callsigns.add(secondary.callsign) 948 949 return resource_event.succeed([primary, pref_res[1], secondary]) 950 951 else: 952 # Roll back if unsuccessful 953 self.active_callsign_groups.discard(primary.callsign_group) 954 self.active_registrations.discard(primary.registration) 955 self.active_callsigns.discard(primary.callsign) 956 957 return resource_event.succeed([None, pref_res[1], None]) 958 959 self.env.process(process()) 960 return resource_event
Attempt to allocate a resource from the preferred group (REG jobs).