Chapter 6: Resource Allocation

How HEMSAvailability picks a primary and secondary resource for each patient, records why, and releases them afterwards.

Chapter 5 ended with the FilterStore full of HEMS objects and the daily servicing check shuffling them in and out. This chapter is about the other consumer of that store: the code that takes a resource out for a patient.

What it is

Resource allocation is the part of HEMSAvailability that answers one question per patient: which vehicle, if any, goes to this job? The input is a Patient (see Chapter 4) with its care category, hour, month, preferred callsign group, preferred vehicle type and helicopter benefit already sampled. The output is a SimPy event that resolves to a three-element list: [primary, reason_text, secondary]. primary is a HEMS or None, reason_text is a string from LOOKUP_LIST keyed by ResourceAllocationReason, and secondary is a second HEMS from the same callsign group or None.

Missed call: a patient for whom allocation returned None. The model still runs a patient_journey for them, but no HEMS resource is used.

The invariants callers rely on:

  • A resource handed out has in_use = True and its callsign group, registration and callsign are in the three active_* sets until return_resource is called.
  • None as the primary is a normal result, not an error. DES_HEMS turns it into a missed call.
  • The reason text is always a valid index into LOOKUP_LIST, including for None.

A concrete case: a CC call at 10:00 in January where the H70 CC helicopter is out on another job. H71 (EC, 07-17 in winter) is on shift, so allocation falls through to it, reports a reason code (CC_MATCH_EC_HELI, listed below), and patient_journey runs with that vehicle. This path is only taken for about half of CC calls (the 0.5 draw below). The collaborators are the HEMS objects and rota checks from Chapter 5, the ad hoc unavailability sampling from Chapter 3, and the caller in DES_HEMS.

How it works

Two entry points, chosen by the caller

The routing decision lives in DES_HEMS, not in HEMSAvailability:

air_ambulance_des/des_hems.py:432-443
            if (
                pt.hems_cc_or_ec == "CC" or pt.hems_cc_or_ec == "EC"
            ) and self.utils.rngs["know_cc_ec_benefit"].uniform(0, 1) <= 0.5:
                hems_res_list: list[
                    HEMS | None, str, HEMS | None
                ] = yield self.hems_resources.allocate_resource(pt)
            else:
                # Separate function to determine HEMS resource based on the preferred callsign group
                # (which is sampled from historical data), the preferred vehicle type
                hems_res_list: list[
                    HEMS | None, str, HEMS | None
                ] = yield self.hems_resources.allocate_regular_resource(pt)

Only CC and EC patients can reach allocate_resource, and then only half the time: the 0.5 is a literal here, drawn from the know_cc_ec_benefit stream. Everyone else, including the other half of CC/EC patients, goes through allocate_regular_resource, which uses the sampled preferred callsign group and vehicle type.

The reason codes

air_ambulance_des/class_hems_availability.py:16-35
# Probability that, for a REG job where a helicopter is beneficial, this is known in advance
# when choosing which resource to send
PROB_HELI_BENEFIT_KNOWN = 0.5


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

ResourceAllocationReason is an IntEnum whose values are used as list indices into HEMSAvailability.LOOKUP_LIST (set in __init__, lines 60-75), via resource_allocation_lookup(). The enum and the list must stay in step by hand. Codes 1-6 belong to the CC/EC path, 7-8 and 9-13 to the REG path. OTHER_VEHICLE_TYPE (11) is no longer produced: the only code that used it is commented out in allocate_regular_resource.

Choosing for CC and EC: preferred_resource_available

This method scans self.store.items and does not take anything out of the store. First a hard filter, then an ad hoc check, then a priority ranking.

air_ambulance_des/class_hems_availability.py:516-598
        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

            # 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)]

        # Final fallback: return the best match found (if any), or none with reason
        self.debug(
            f"Selected best lookup: {best_lookup.name} ({best_lookup.value}) with priority = {best_priority}"
        )
        return [best_hems, self.resource_allocation_lookup(best_lookup)]

The filter drops any unit that is in use, being serviced, off shift for pt.hour and pt.month, or whose callsign group or registration is in the active sets. Cars skip the ad hoc check; helicopters call Utils.sample_ad_hoc_reason(pt.hour, pt.qtr, h.registration) and are skipped unless it returns "available". Note the mixed time keys: shifts use month, ad hoc rates use quarter.

Ranking is 1 (right category, helicopter), 2 (right category, car), then 3 and 4 for the other category (helicopter, then car). Only a strict < replaces the current best, so ties go to whichever unit comes first in store.items. Priority 1 returns immediately. Anything else means the whole store is scanned, and every helicopter in it consumes an ad hoc draw.

Choosing for REG: preferred_regular_group_available

Same filter and ad hoc check, then a branch on whether a helicopter benefit is “known” (the module constant PROB_HELI_BENEFIT_KNOWN, line 18). This method is also used for CC/EC patients who miss the 0.5 draw, so they can be subject to the same helicopter-benefit logic. That decision is drawn once per patient, before the loop:

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
        )

Both conditions matter: helicopter_benefit == "y" is short-circuited first, so patients without a benefit never consume a draw from know_heli_benefit. Then the branches:

air_ambulance_des/class_hems_availability.py:938-989
            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

If the benefit is known, only helicopters can be chosen (CC helicopter first, then any), so a car is never sent in its place. If not, the order is preferred group and vehicle (immediate break), any helicopter, preferred group only, then anything free. The unused result for a known benefit with no helicopter free is NONE_AVAILABLE.

Claiming: allocate_resource and allocate_regular_resource

Both wrap the scan in a SimPy process and return the event.

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 sequence after a candidate is found:

  1. Re-check the three active sets (lines 619-629). Nothing yields between scan and re-check, so in this single-threaded SimPy process these guards look redundant. The code suggests they were added defensively (inferred).
  2. Add group, registration and callsign to the active sets before yielding. This is the claim.
  3. store.get(lambda r: r == primary) raced against env.timeout(0.1), so a lost race costs 0.1 minutes; the separate secondary search uses its own 0.1-minute timeout (see Sharp edges). On success set in_use and copy group, vehicle type and category onto the patient.
  4. Look for a secondary, then resource_event.succeed([primary, reason, secondary]).
  5. If the claim lost the race, roll the three sets back and return None.

allocate_regular_resource (lines 1002-1086) is a line-for-line copy of this body that calls preferred_regular_group_available instead. Figure 1 shows the shared shape.

flowchart TD
    A[DES_HEMS.generate_patient] --> B{CC or EC and draw <= 0.5}
    B -- yes --> C[allocate_resource]
    B -- no --> D[allocate_regular_resource]
    C --> E[preferred_resource_available]
    D --> F[preferred_regular_group_available]
    E --> G{candidate found}
    F --> G
    G -- no --> H[succeed with None]
    G -- yes --> I[add to active sets]
    I --> J[store.get primary or timeout 0.1]
    J -- timed out --> K[roll back sets, None]
    J -- got it --> L[try secondary]
    L --> M[succeed with primary, reason, secondary]
    H --> N[missed call: patient_journey with None]
    K --> N
    M --> O[patient_journey with resources]
Figure 1: Allocation path for one patient

Releasing

DES_HEMS.patient_journey returns resources from a finally block, so they come back even if the journey raises:

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"
                )
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"
            )

return_resource is the mirror image of the claim: clear in_use, discard from the three sets, put back into store. If the resource has become due for service meanwhile, the next daily_servicing_check moves it (Chapter 5). Everything downstream, such as utilisation and missed-job counts in Chapter 7, reads the resource_preferred_outcome event that stores the reason text.

Extending this

Add a new allocation reason. Add a member to ResourceAllocationReason and a string at the matching index of LOOKUP_LIST. Append rather than renumber: the values are indices and, as far as the repo shows, the text is what lands in the event log, so renaming strings will change downstream groupings (inferred; check the consumers in Chapter 7 before editing text).

Change a priority order. Edit the if/elif chains in preferred_resource_available (CC/EC) or preferred_regular_group_available (REG). Keep the continue-filter at the top of the loop intact: it is the only place that enforces “not in use, not in service, on shift, group and registration not active”.

Change how often each path is used. The CC/EC split is the literal 0.5 at des_hems.py:434. The helicopter-benefit probability is PROB_HELI_BENEFIT_KNOWN at class_hems_availability.py:18.

Change claiming or secondary behaviour. allocate_resource and allocate_regular_resource are duplicates, so make every change twice (or, better, factor out the shared body; nothing in the repo prevents it).

Add new resources. add_hems() is a stub. In practice a new vehicle is a new row in the rota and lookup CSVs read by prep_HEMS_resources (Chapter 5). Allocation code then sees it automatically, but the hierarchy hard-codes categories "CC" and "EC" and vehicle types "helicopter" and "car". Open issue #211 discusses making this flexible (open as of 2026-10-19).

Tests.

  • tests/test_resource_allocation.py calls preferred_regular_group_available directly, with sample_ad_hoc_reason and the know_heli_benefit stream mocked. It covers: one draw per patient, no car when a benefit is known and no helicopter is free, and the preferred unit being sent when it is not known. There are no direct tests yet for allocate_resource or the CC/EC ranking.
  • tests/test_unittest_model.py checks resource behaviour over full runs.

Run with pytest tests/test_resource_allocation.py, or pytest -m resources for the tagged resource tests (documented in the README testing section).

Sharp edges

WarningThe secondary resource can never be allocated as written

The claim step adds primary.callsign_group to active_callsign_groups (line 631). The secondary filter then requires r.callsign_group == pt.hems_callsign_group (the same group) and r.callsign_group not in self.active_callsign_groups. Those can’t both hold, so secondary is always None. The commented-out add lines at 644-646 suggest the claim used to happen after the secondary search, which would have made the filter satisfiable (inferred). If you rely on the secondary slot, verify it before building on it.

  • Fixed cost of 0.1 minutes. The secondary search yields on secondary_request | env.timeout(0.1). With no match, the timeout fires, so every successful allocation advances the clock by 0.1 simulated minutes before the journey starts (follows from the code).
  • Tie-breaking depends on store order. FilterStore.put appends, so after a few jobs the order of store.items differs from the rota order. Equal-priority candidates are resolved by that order, which is history-dependent.
  • RNG draws depend on scan breadth. Each helicopter that passes the filter consumes an ad hoc draw. A change that alters how many candidates are scanned (an early break, a new filter) shifts the ad hoc stream for all later patients, even if the allocation outcome is identical. See the named streams in Chapter 3.
  • Cars never sample unavailability. Documented, not accidental (commit c705e99, issue #170). Issue #241 reports cars sent when the ad hoc data gives no obvious reason.
  • Labels are coarser than the logic. REG_HELI_BENEFIT_MATCH_EC_HELI is assigned to any non-CC helicopter in the known-benefit branch, not only EC ones.
  • State lives in four places. in_use, three active_* sets and the store contents must agree. Only allocate_* and return_resource should touch the sets. The g-daan callsign switch in daily_servicing_check rewrites callsign and callsign_group on items in the store; the sets are keyed on those values, so don’t change them for a resource that is out on a job.
  • Dead code. Large commented-out copies of both preferred_* methods and resource_allocation_lookup remain in the file. Edit the live versions (lines 492 and 862).
  • Debug output is opt-in. current_store_status and the debug() calls only log when print_debug_messages is true, and use logging.debug, so turn on both logging and the flag.

Design notes

Documented.

  • The class was rewritten around lookups that are easier to follow, with comments added afterwards (commits b476b8d, ac98121).
  • The active sets exist to stop a crew or vehicle being allocated twice: a fix for simultaneous allocation of the same resource (commit 8f42554, with c210f81 reporting no overlap and unit tests passing).
  • Ad hoc unavailability sampling for helicopters arrived in commit 96d6f97; cars were excluded in c705e99 (issue #170).
  • Helicopter benefit is not always known in advance, and the logic was changed to use the regular allocation path (commit 9b559c2). The timing of that check was later adjusted and tests added (commit 2bd23f9).
  • The preferred callsign group is derived without care category stratification, using only cases where no other resource was tasked (commit 7fa1a27); the “other” callsign group check was removed (commit a10d45f).

Inferred.

  • Returning a list [resource, reason, secondary] rather than a richer object looks like a legacy of the original tuple-style code; callers index it positionally (hems_res_list[0], [1], [2]).
  • Using an event plus an inner process, rather than a plain generator, lets DES_HEMS yield the result in one line while the method can still take its own timeouts.

Limitations and open questions

NoteWhy the model makes simplifying assumptions

Every simulation model rests on assumptions and simplifications, and it has to. No model can be a perfect copy of reality. Adding ever more detail does not fix that, and can make things worse: each extra detail is one more thing to get wrong, so an over-complicated model can drift further from reality, not closer. Judge the items below, and any change you are tempted to make, against whether the assumption is reasonable for the questions asked of the model. Closing every gap with reality is not the aim, and extra fidelity can add more error than it removes.

No TODO/FIXME markers exist in this file. Open issues, as of 2026-10-19:

  • #238: revisiting the allocation logic itself.
  • #178: allocation between callsign groups 70 and 71 differs slightly from history.
  • #211: solo versus backup cars, and flexibility for added resources.
  • #243: allocation ignores distance when deciding whether to send a car.
  • #84: allocation logic should be more prominent in the model logic diagram.

See the issue tracker for the current state.

Conclusion

Next, in Chapter 7, we follow the event log that allocation feeds into, and see how TrialResults turns it into utilisation and missed-job metrics.