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
class ResourceAllocationReason(enum.IntEnum):
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
MATCH_PREFERRED_CARE_CAT_HELI = <ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_HELI: 1>
MATCH_PREFERRED_CARE_CAT_CAR = <ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_CAR: 2>
REG_HELI_BENEFIT_MATCH_EC_HELI = <ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_EC_HELI: 7>
REG_HELI_BENEFIT_MATCH_CC_HELI = <ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_CC_HELI: 8>
REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE = <ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE: 9>
REG_NO_HELI_BENEFIT_GROUP = <ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP: 10>
OTHER_VEHICLE_TYPE = <ResourceAllocationReason.OTHER_VEHICLE_TYPE: 11>
REG_NO_HELI_BENEFIT_ANY = <ResourceAllocationReason.REG_NO_HELI_BENEFIT_ANY: 12>
REG_NO_HELI_BENEFIT_VEHICLE = <ResourceAllocationReason.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
class HEMSAvailability:
 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

HEMSAvailability( env, sim_start_date, sim_duration, utility: utils.Utils, servicing_overlap_allowed=False, servicing_buffer_weeks=4, servicing_preferred_month=1, print_debug_messages=False, master_seed=SeedSequence( entropy=42, ))
 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)
LOOKUP_LIST
env
print_debug_messages
master_seed
utilityClass
servicing_overlap_allowed
serviing_buffer_weeks
servicing_preferred_month
sim_start_date
sim_end_date
school_holidays
HEMS_resources_list
active_callsign_groups
active_registrations
active_callsigns
store
serviceStore
def debug(self, message: str):
104    def debug(self, message: str):
105        if self.print_debug_messages:
106            logging.debug(message)
107            #print(message)
def daily_servicing_check(self, current_dt: datetime.datetime, hour: int, month: int):
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

def prep_HEMS_resources(self) -> None:
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

def populate_store(self):
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

def add_hems(self):
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

def resource_allocation_lookup(self, reason: ResourceAllocationReason) -> str:
337    def resource_allocation_lookup(self, reason: ResourceAllocationReason) -> str:
338        return self.LOOKUP_LIST[reason.value]
def current_store_status(self, hour, month, store='resource') -> list[str]:
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

def preferred_resource_available(self, pt: class_patient.Patient) -> list[class_hems.HEMS | None, str]:
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

def allocate_resource(self, pt: class_patient.Patient) -> typing.Any | simpy.events.Event:
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.

def return_resource( self, resource: class_hems.HEMS, secondary_resource: class_hems.HEMS | None) -> None:
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.

def years_between( self, start_date: datetime.datetime, end_date: datetime.datetime) -> list[int]:
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

def do_ranges_overlap( self, start1: datetime.datetime, end1: datetime.datetime, start2: datetime.datetime, end2: datetime.datetime) -> bool:
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

def is_during_school_holidays(self, start_date: datetime.datetime, end_date: datetime.datetime) -> bool:
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

def is_other_resource_being_serviced(self, start_date, end_date, service_dates):
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.

def find_next_service_date( self, last_service_date: datetime.datetime, interval_months: int, service_dates: list, service_duration: int) -> list[datetime.datetime]:
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.

def preferred_regular_group_available(self, pt: class_patient.Patient) -> list[class_hems.HEMS | None, str]:
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

def allocate_regular_resource(self, pt: class_patient.Patient) -> typing.Any | simpy.events.Event:
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).