Chapter 6: Resource Allocation

How HEMSAvailability chooses a primary and secondary resource for each patient, records the reason, and releases them afterwards.

In HEMS Resources and Servicing we saw how each helicopter and car becomes a HEMS object, and how those objects sit in a SimPy FilterStore that the daily servicing check keeps up to date. This chapter is about what happens when a patient turns up and something has to be picked out of that store.

The problem: who goes to this patient?

Take one concrete case. It is 10:00 in June. A call comes in that the model has classed as critical care (CC). H70, the critical care helicopter (aircraft g-daas), is out on another job, which also makes its group-mate CC70 ineligible, because callsign group 70 is already active. H71 (an enhanced care helicopter) and CC72 (an enhanced care car) are on shift and idle. Which of these goes, and how do we explain the choice afterwards? (The callsigns are those in the shipped rota, actual_data/HEMS_ROTA.csv, described in the previous chapter.)

The answer lives in HEMSAvailability, the same class that built the store in the previous chapter. Its job here is to:

  1. rank the resources that are currently eligible,
  2. take the best one out of the store (the primary resource), and optionally a second one from the same crew, such as the car that shares a helicopter’s callsign group (the secondary resource; see the bug note below on whether it can ever be found),
  3. return a text reason describing why that resource was chosen, and
  4. put everything back when the job ends.

If nothing is eligible, the patient is a missed call: the allocation comes back as None, and the journey in Calls and Patient Journeys records “No Resource Available” instead of sending a HEMS crew.

Missed call: a job where allocation returned no HEMS resource, so the journey logs “No HEMS available” and “No Resource Available” rather than any HEMS activity.

Key concepts

Two allocation routes

The model has two routes, because real dispatchers sometimes know what a job needs and sometimes do not. In generate_patient, a CC or EC patient only takes the first route half of the time; everyone else (all REG patients, and the other half of CC/EC) takes the second.

As seen in Calls and Patient Journeys, generate_patient makes this choice, using the know_cc_ec_benefit draw from one of the named random streams described in Sampling Distributions and Random Streams. Both routes are SimPy events, so the yield pauses the patient until the allocation process has finished, and the result is a three-item list: primary resource, reason text, secondary resource.

Route Method Ranks resources by
Care need known (CC or EC patients, half of the time) allocate_resource() care category and vehicle type match
Everything else allocate_regular_resource() preferred callsign group, preferred vehicle type and helicopter benefit

Preferred callsign group / vehicle type: values sampled for each patient from historical data in generate_patient (pt.hems_pref_callsign_group, pt.hems_pref_vehicle_type). They express what historically tended to be sent, not what is available now.

The reason enum

Every outcome has a name. ResourceAllocationReason is an IntEnum, and a parallel LOOKUP_LIST in HEMSAvailability.__init__ maps each value to the text that ends up in the event log. For example, MATCH_PREFERRED_CARE_CAT_HELI (1) means the first-choice resource, a helicopter of the right care category, was free, while CC_MATCH_EC_HELI (3) means a critical care patient got an enhanced care helicopter because no better match was free.

air_ambulance_des/class_hems_availability.py:21-35
class ResourceAllocationReason(IntEnum):
    NONE_AVAILABLE = 0
    MATCH_PREFERRED_CARE_CAT_HELI = 1
    MATCH_PREFERRED_CARE_CAT_CAR = 2
    CC_MATCH_EC_HELI = 3
    CC_MATCH_EC_CAR = 4
    EC_MATCH_CC_HELI = 5
    EC_MATCH_CC_CAR = 6
    REG_HELI_BENEFIT_MATCH_EC_HELI = 7
    REG_HELI_BENEFIT_MATCH_CC_HELI = 8
    REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE = 9
    REG_NO_HELI_BENEFIT_GROUP = 10
    OTHER_VEHICLE_TYPE = 11
    REG_NO_HELI_BENEFIT_ANY = 12
    REG_NO_HELI_BENEFIT_VEHICLE = 13

Values 1 to 6 belong to the care-category route and 7 to 13 to the regular route. (Value 11 is defined but the code that used it is commented out.) The text is written to the event log as a resource_preferred_outcome row, which is how the results classes in Trial Results and Historic Comparison can later break down why each job was served the way it was.

Active sets: not sending a crew twice

A helicopter and a car in the same callsign group share a crew, and one aircraft can fly under two callsigns (the H70/H71 swap from the previous chapter). HEMSAvailability therefore keeps three sets: active_callsign_groups, active_registrations and active_callsigns. Ranking skips anything whose group or registration is in a set, and allocation additionally refuses a primary whose callsign is in active_callsigns, even if the in_use flag has not yet been set.

Active callsign groups: the set of crews currently out on a job. Resources are added when allocated and removed in return_resource().

How a decision is made

Figure 1 shows the shape of the decision. Both routes share the same first step: filter out anything that cannot go.

flowchart TD
    P[Patient needs a resource] --> Q{CC or EC and<br/>care need known?}
    Q -- yes --> A[allocate_resource]
    Q -- no --> B[allocate_regular_resource]
    A --> C[preferred_resource_available]
    B --> D[preferred_regular_group_available]
    C --> F[Filter: idle, on shift,<br/>not serviced, not active]
    D --> F
    F --> G[Ad hoc check for helicopters]
    G --> H[Rank remaining candidates]
    H --> I{Candidate found?}
    I -- no --> N[Return None plus reason]
    I -- yes --> J[Take primary from store]
    J --> K[Try for secondary from same group]
    K --> R[Return primary, reason, secondary]
Figure 1: The two allocation routes and what they share

The sequence of events

Figure 2 follows our 10:00 case through the code.

sequenceDiagram
    participant DH as DES_HEMS
    participant HA as HEMSAvailability
    participant ST as FilterStore
    participant UT as Utils
    DH->>HA: allocate_resource(pt)
    HA->>HA: preferred_resource_available(pt)
    loop each item in store
        HA->>UT: sample_ad_hoc_reason (helicopters only)
        UT-->>HA: available or a reason
    end
    HA->>ST: get(primary)
    ST-->>HA: primary
    HA->>ST: get(secondary)
    HA-->>DH: primary, reason text, secondary
    Note over DH: patient_journey runs
    DH->>HA: return_resource(primary, secondary)
    HA->>ST: put(primary)
Figure 2: One CC patient being allocated and later released

Under the hood: ranking the candidates

Care-category route

preferred_resource_available() walks self.store.items (only resources currently in the store, so anything in the service store is already out of the picture). The first part is a filter.

air_ambulance_des/class_hems_availability.py:516-546
        for h in self.store.items:
            # --- FILTER OUT UNAVAILABLE RESOURCES ---
            if (
                h.in_use  # Already dispatched
                or h.being_serviced  # Currently under maintenance
                or not h.hems_resource_on_shift(
                    pt.hour, pt.month
                )  # Not scheduled for shift now
                or h.callsign_group
                in self.active_callsign_groups  # Another unit from this group is active (so crew is engaged elsewhere)
                or h.registration
                in self.active_registrations  # This specific unit is already dispatched
            ):
                continue  # Move to the next HEMS unit

            # Check ad-hoc reason
            # For "car" units, assume always available.
            # For helicopters, simulate availability using ad-hoc logic (e.g., weather, servicing).
            reason = (
                "available"
                if h.vehicle_type == "car"
                else self.utilityClass.sample_ad_hoc_reason(
                    pt.hour, pt.qtr, h.registration
                )
            )
            self.debug(
                f"({h.callsign}) Sampled reason for patient {pt.id} ({pt.hems_cc_or_ec}) is: {reason}"
            )

            if reason != "available":
                continue  # Skip this unit if not usable

A resource is skipped if it is in use, being serviced, off shift for pt.hour and pt.month, or if its callsign group or registration is already active. Cars are always treated as available. Helicopters then get one more test: Utils.sample_ad_hoc_reason() (see Sampling Distributions and Random Streams) draws a reason such as weather from the historical ad hoc unavailability rates for that hour, quarter and registration. Anything other than "available" removes that helicopter for this patient only.

What survives is ranked.

air_ambulance_des/class_hems_availability.py:548-592
            # Decide priority and reason
            priority = None
            lookup = None

            # 1. Best case: resource category matches preferred care category *and* is a helicopter
            if h.category == preferred_category and h.vehicle_type == "helicopter":
                priority = 1
                lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_HELI

            # 2. Next best: resource category matches preferred care category, but is a car
            elif h.category == preferred_category:
                priority = 2
                lookup = ResourceAllocationReason.MATCH_PREFERRED_CARE_CAT_CAR

            # 3–4. Category mismatch fallback options:
            # For a CC preference, fall back to EC providers if needed
            elif preferred_category == "CC":
                if h.category == "EC" and h.vehicle_type == "helicopter":
                    priority = 3
                    lookup = ResourceAllocationReason.CC_MATCH_EC_HELI
                elif h.category == "EC":
                    priority = 4
                    lookup = ResourceAllocationReason.CC_MATCH_EC_CAR

            # For an EC preference, fall back to CC providers if needed
            elif preferred_category == "EC":
                if h.category == "CC" and h.vehicle_type == "helicopter":
                    priority = 3
                    lookup = ResourceAllocationReason.EC_MATCH_CC_HELI
                elif h.category == "CC":
                    priority = 4
                    lookup = ResourceAllocationReason.EC_MATCH_CC_CAR

            # --- CHECK IF THIS IS THE BEST OPTION SO FAR ---
            if priority is not None and priority < best_priority:
                best_hems = h
                best_priority = priority
                best_lookup = lookup

                # Immediate return if best possible match found (priority 1)
                if priority == 1:
                    self.debug(
                        f"Top priority match found: {best_lookup.name} ({best_lookup.value})"
                    )
                    return [best_hems, self.resource_allocation_lookup(best_lookup)]

Lower priority numbers win. An exact category match on a helicopter is priority 1 and returns immediately. Otherwise the loop keeps the best so far: same category car (2), then a helicopter from the other care category (3), then a car from the other category (4). For our 10:00 CC patient this means: H70 is busy and CC70 shares its group (both filtered out). H71 and CC72 are both category EC, so for a CC patient H71, an EC helicopter, is priority 3 and CC72, an EC car, is priority 4. H71 wins, with the reason “HEMS CC case EC helicopter available”. Note that only CC and EC patients can be handled here: the fallbacks are written for those two categories, which is why REG patients use the other route.

Regular route

preferred_regular_group_available() uses the same filter, but ranks differently. Before the loop it makes a one-off decision per patient.

air_ambulance_des/class_hems_availability.py:891-899
        # Decide once per patient whether it is known in advance that a helicopter will be
        # beneficial when choosing which resource to send (with probability
        # PROB_HELI_BENEFIT_KNOWN). If it is known, only helicopters are considered, so a car
        # is never sent instead.
        heli_benefit_known = (
            helicopter_benefit == "y"
            and self.utilityClass.rngs["know_heli_benefit"].uniform(0, 1)
            <= PROB_HELI_BENEFIT_KNOWN
        )

If the patient has a helicopter benefit, there is still only a 50% chance (PROB_HELI_BENEFIT_KNOWN) that the dispatcher knows about it up front. That draw uses the know_heli_benefit stream. Then the ranking:

air_ambulance_des/class_hems_availability.py:933-989
            # --- HELICOPTER BENEFIT CASE ---
            # P3 = Helicopter patient
            # Resources allocated in following order:
            # IF H70 available = SEND
            # ELSE H71 available = SEND
            if heli_benefit_known:
                # Priority 1: CC-category helicopter (assumed most beneficial)
                if h.vehicle_type == "helicopter" and h.category == "CC":
                    hems = h
                    preferred_lookup = (
                        ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_CC_HELI
                    )
                    break
                # Priority 2: Any helicopter (less preferred than CC, hence priority = 2)
                elif h.vehicle_type == "helicopter" and preferred > 2:
                    hems = h
                    preferred = 2
                    preferred_lookup = (
                        ResourceAllocationReason.REG_HELI_BENEFIT_MATCH_EC_HELI
                    )

            # If no EC or CC helicopters are available, then:
            # - hems remains None
            # - preferred_lookup remains at its initial value (ResourceAllocationReason.NONE_AVAILABLE)
            # - The function exits the loop without assigning a resource.

            # --- REGULAR JOB WITH NO SIMULATED HELICOPTER BENEFIT ---
            else:
                # Best match: matching both preferred callsign group and vehicle type
                if (
                    h.callsign_group == preferred_group
                    and h.vehicle_type == preferred_vehicle_type
                ):
                    hems = h
                    preferred_lookup = (
                        ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP_AND_VEHICLE
                    )
                    break
                # Next best: send a helicopter
                elif h.vehicle_type == "helicopter" and preferred > 3:
                    hems = h
                    preferred = 3
                    preferred_lookup = (
                        ResourceAllocationReason.REG_NO_HELI_BENEFIT_VEHICLE
                    )
                # Next best: match only on preferred callsign group
                elif h.callsign_group == preferred_group and preferred > 4:
                    hems = h
                    preferred = 4
                    preferred_lookup = (
                        ResourceAllocationReason.REG_NO_HELI_BENEFIT_GROUP
                    )
                # Fallback: any available resource
                elif preferred > 5:
                    hems = h
                    preferred = 5
                    preferred_lookup = ResourceAllocationReason.REG_NO_HELI_BENEFIT_ANY

When the benefit is known, only helicopters are considered, CC before any other. Otherwise the order is:

  1. preferred group and vehicle type (immediate break),
  2. any helicopter,
  3. preferred group only,
  4. any free resource.

For example, take a REG patient with preferred group 70 and vehicle type car, and no helicopter benefit. If H70 is busy and CC70 is therefore ineligible too, no resource matches both, so H71 (a free helicopter) wins at step 2. This is why a REG job can still send a helicopter when no helicopter benefit was flagged; the REG_NO_HELI_BENEFIT_VEHICLE branch ranks helicopters ahead of a group-only match.

Under the hood: claiming and releasing

store.get(filter) returns an event that succeeds when an item matching the filter is available. Yielding get_event | env.timeout(0.1) waits for whichever happens first. If the timeout wins, the get has not succeeded, and the with block cancels the pending request on exit.

The ranking functions only choose; they do not take anything from the store. That is done by allocate_resource() (and its twin allocate_regular_resource(), which differs only in calling the other ranking function and in debug text).

air_ambulance_des/class_hems_availability.py:600-678
    def allocate_resource(self, pt: Patient) -> Any | Event:
        """
        Attempt to allocate a resource from the preferred group.
        """
        resource_event: Event = self.env.event()

        def process() -> Generator[Any, Any, None]:
            self.debug(
                f"Allocating resource for {pt.id} and care cat {pt.hems_cc_or_ec}"
            )

            pref_res: list[HEMS | None, str] = self.preferred_resource_available(pt)

            if pref_res[0] is None:
                return resource_event.succeed([None, pref_res[1], None])

            primary = pref_res[0]

            # Block if in-use by callsign group, registration, or callsign
            if primary.callsign_group in self.active_callsign_groups:
                self.debug(f"[BLOCKED] Callsign group {primary.callsign_group} in use")
                return resource_event.succeed([None, pref_res[1], None])

            if primary.registration in self.active_registrations:
                self.debug(f"[BLOCKED] Registration {primary.registration} in use")
                return resource_event.succeed([None, pref_res[1], None])

            if primary.callsign in self.active_callsigns:
                self.debug(f"[BLOCKED] Callsign {primary.callsign} already in use")
                return resource_event.succeed([None, pref_res[1], None])

            self.active_callsign_groups.add(primary.callsign_group)
            self.active_registrations.add(primary.registration)
            self.active_callsigns.add(primary.callsign)

            with self.store.get(lambda r: r == primary) as primary_request:
                result = yield primary_request | self.env.timeout(0.1)

                if primary_request in result:
                    primary.in_use = True
                    pt.hems_callsign_group = primary.callsign_group
                    pt.hems_vehicle_type = primary.vehicle_type
                    pt.hems_category = primary.category

                    # self.active_callsign_groups.add(primary.callsign_group)
                    # self.active_registrations.add(primary.registration)
                    # self.active_callsigns.add(primary.callsign)

                    # Try to get a secondary resource
                    with self.store.get(
                        lambda r: r != primary
                        and r.callsign_group == pt.hems_callsign_group
                        and r.category == pt.hems_category
                        and r.hems_resource_on_shift(pt.hour, pt.month)
                        and r.callsign_group not in self.active_callsign_groups
                        and r.registration not in self.active_registrations
                        and r.callsign not in self.active_callsigns
                    ) as secondary_request:
                        result2 = yield secondary_request | self.env.timeout(0.1)
                        secondary = None

                        if secondary_request in result2:
                            secondary = result2[secondary_request]
                            secondary.in_use = True
                            self.active_callsign_groups.add(secondary.callsign_group)
                            self.active_registrations.add(secondary.registration)
                            self.active_callsigns.add(secondary.callsign)

                    return resource_event.succeed([primary, pref_res[1], secondary])
                else:
                    # Roll back if unsuccessful
                    self.active_callsign_groups.discard(primary.callsign_group)
                    self.active_registrations.discard(primary.registration)
                    self.active_callsigns.discard(primary.callsign)

                    return resource_event.succeed([None, pref_res[1], None])

        self.env.process(process())
        return resource_event

The method creates an event and starts a nested process() generator, so the caller can simply yield the event. Inside, a few things matter.

  • Early outs. If nothing was ranked, or the chosen resource’s group, registration or callsign is already active, the event succeeds immediately with [None, reason, None].
  • Claim before waiting. The three active sets are updated before the yield, so another patient processed in the same simulated instant cannot grab the same crew.
  • Short timeout. The store.get(lambda r: r == primary) races against env.timeout(0.1). If the get does not complete, the sets are rolled back and the patient gets None.
  • Patient fields. On success, the patient is stamped with hems_callsign_group, hems_vehicle_type and hems_category, which patient_journey later logs.
WarningPossible bug: the secondary resource may never be found

The secondary get requires r.callsign_group not in self.active_callsign_groups, and it also requires r.callsign_group == pt.hems_callsign_group. The primary’s group was added to active_callsign_groups a few lines earlier, so no resource can satisfy both conditions, and secondary is always None (the logged hems_res_list values in tests/test_logs/ also always end in None). No test in tests/ covers a secondary resource. The wait for the impossible secondary also always runs to its 0.1-minute timeout, so each successful allocation delays the start of patient_journey by 0.1 simulated minutes. If you rely on secondary resources in results, check this before trusting them.

Releasing

When the journey ends, the finally block in patient_journey hands the resources back, and this is true even if the journey raised an error.

air_ambulance_des/des_hems.py:712-721
        finally:
            # Always return the resource at the end of the patient journey.
            if hems_res is not None:
                self.hems_resources.return_resource(hems_res, secondary_hems_res)
                self.debug(
                    f"Attempting to return {hems_res} and {secondary_hems_res} to resource store"
                )
                self.add_patient_result_row(
                    patient, hems_res.callsign, "resource_use_end"
                )

return_resource() does the inverse of the claim.

air_ambulance_des/class_hems_availability.py:680-700
    def return_resource(self, resource: HEMS, secondary_resource: HEMS | None) -> None:
        """
        Class to return HEMS class object back to the filestore.
        """

        resource.in_use = False
        self.active_callsign_groups.discard(resource.callsign_group)
        self.active_registrations.discard(resource.registration)
        self.active_callsigns.discard(resource.callsign)
        self.store.put(resource)
        self.debug(f"{resource.callsign} finished job")

        if secondary_resource is not None:
            secondary_resource.in_use = False
            self.active_callsign_groups.discard(secondary_resource.callsign_group)
            self.active_registrations.discard(secondary_resource.registration)
            self.active_callsigns.discard(secondary_resource.callsign)
            self.store.put(secondary_resource)
            self.debug(
                f"{secondary_resource.callsign} free as {resource.callsign} finished job"
            )

It clears in_use, discards the group, registration and callsign from the active sets, and puts the object back into the same FilterStore, making it visible to the next patient. The same is done for the secondary, if there is one.

current_store_status() is a small debugging helper: given an hour and month it lists each item in the main store (or the service store) with its category and whether it is on shift. It is called from the servicing check when debug messages are on.

Conclusion

Allocation is a two-step process: a ranking method picks the best eligible resource and a reason, and an allocation process claims it from the FilterStore, guarded by the active sets. CC and EC patients (when the care need is known) rank by category and vehicle type; everyone else ranks by preferred callsign group, vehicle type and helicopter benefit. The reason is logged for every job, and return_resource() releases everything when the journey ends.

Next, in Trial Results and Historic Comparison, we will see how the event log these decisions leave behind is turned into utilisation figures, missed-job percentages and comparisons with historical data.