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]
Chapter 6: Resource Allocation
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:
- rank the resources that are currently eligible,
- 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),
- return a text reason describing why that resource was chosen, and
- 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 = 13Values 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.
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)
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 usableA 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_ANYWhen the benefit is known, only helicopters are considered, CC before any other. Otherwise the order is:
- preferred group and vehicle type (immediate
break), - any helicopter,
- preferred group only,
- 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_eventThe 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 againstenv.timeout(0.1). If the get does not complete, the sets are rolled back and the patient getsNone. - Patient fields. On success, the patient is stamped with
hems_callsign_group,hems_vehicle_typeandhems_category, whichpatient_journeylater logs.
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.