stateDiagram-v2
[*] --> store: prep_HEMS_resources + populate_store
store --> serviceStore: daily check, service_check is True
serviceStore --> store: daily check, service_check is False
store --> OutOnJob: allocate_resource / allocate_regular_resource (store.get)
OutOnJob --> store: return_resource (store.put)
Chapter 5: HEMS Resources and Servicing
Calls and Patient Journeys ended with a patient asking for a resource. This chapter is about the things being asked for.
What it is
Every helicopter or car in the model is a HEMS object, a subclass of the (very thin) Ambulance. A HEMS carries its identity (callsign, callsign_group, registration, vehicle_type, category), its summer and winter shift hours, and a pre-computed servicing schedule. HEMSAvailability builds all of them from CSVs in actual_data/ (the files the app rewrites, see Chapter 1), holds them in a SimPy FilterStore, and once a simulated day moves them to and from a second store while they are in the workshop.
The contract callers rely on:
HEMSAvailability(...)is fully populated on return:storeholds every resource,serviceStoreis empty until the first daily check.- A resource that is
in_use(out on a job) is not in the store. Anything iteratingstore.itemsonly ever sees idle resources. hems_resource_on_shift(hour, month)andunavailable_due_to_service(dt)are pure lookups; the second also setsbeing_servicedas a side effect.
The use case running through the chapter: the service has two helicopters but wants the callsign H70 always crewed. When H70’s usual aircraft, g-daas (the H145), is in the workshop, the second aircraft, g-daan (the EC135), flies as H70 instead of its usual H71. The model has to know, each simulated day, who is H70 and who is in the workshop. Who is allowed to be sent where, once resources exist, is Chapter 6. DES_HEMS constructs the availability object (Chapter 4) and triggers the daily check from generate_calls.
How it works
Figure 1 shows the whole life of a resource: built once, then bounced between the stores.
The HEMS object
air_ambulance_des/class_hems.py:15-69
def __init__(
self,
callsign: str,
callsign_group: str,
vehicle_type: str,
category: str,
registration: str,
summer_start: str,
winter_start: str,
summer_end: str,
winter_end: str,
servicing_schedule: pd.DataFrame,
resource_id: str = None,
summer_season: list[int] = None,
):
# Inherit all parent class functions
super().__init__(ambulance_type="HEMS")
self.utilityClass = Utils()
self.callsign = callsign
self.callsign_group = callsign_group
self.available = 1
self.being_serviced = False
self.flying_time = 0
self.vehicle_type = vehicle_type
self.category = category
self.registration = registration
self.summer_start = summer_start
self.winter_start = winter_start
self.summer_end = summer_end
self.winter_end = winter_end
# Read in the summer months from a file
summer_months = pd.read_csv("actual_data/rota_start_end_months.csv")
summer_start_month = int(
summer_months[summer_months["what"] == "summer_start_month"][
"month"
].values[0]
)
summer_end_month = int(
summer_months[summer_months["what"] == "summer_end_month"]["month"].values[
0
]
)
self.summer_season = [
x for x in range(summer_start_month, summer_end_month + 1)
]
# Pre-determine the servicing schedule when the resource is created
self.servicing_schedule = servicing_schedule
self.in_use = False
self.resource_id = resource_idThe shift hours arrive as constructor arguments, but the definition of summer does not: lines 48-63 (the block under “Read in the summer months from a file”) read actual_data/rota_start_end_months.csv every time a HEMS is built, relative to the current working directory. The default file says April to October. Each object also carries being_serviced and in_use flags, and note that Ambulance.__init__ sets a default callsign that the subclass immediately overwrites.
The three methods that matter at run time:
air_ambulance_des/class_hems.py:71-120
def service_check(self, current_dt: pd.Timestamp, GDAAS_service: bool) -> bool:
if self.registration == "g-daan":
if GDAAS_service:
self.callsign_group = 70
self.callsign = "H70"
else:
self.callsign_group = 71
self.callsign = "H71"
self.in_use = (
False # We might need to re-think this in 24/7 scenarios although
)
# presumably the callsign will not change during an incident, only after.
# GDASS being serviced
# print(f"reg {self.registration} now has callsign {self.callsign}")
return self.unavailable_due_to_service(current_dt)
def unavailable_due_to_service(self, current_dt: pd.Timestamp) -> bool:
"""
Returns logical value denoting whether the HEMS resource is currently
unavailable due to being serviced
"""
for index, row in self.servicing_schedule.iterrows():
# print(row)
if row["service_start_date"] <= current_dt <= row["service_end_date"]:
self.being_serviced = True
return True
self.being_serviced = False
return False
def hems_resource_on_shift(self, hour: int, month: int) -> bool:
"""
Function to determine whether the HEMS resource is within
its operational hours
"""
# Assuming summer hours are quarters 2 and 3 i.e. April-September
# Can be modified if required.
# SR NOTE: If changing these, please also modify in
# write_run_params() function in des_parallel_process
start = self.summer_start if month in self.summer_season else self.winter_start
end = self.summer_end if month in self.summer_season else self.winter_end
return self.utilityClass.is_time_in_range(int(hour), int(start), int(end))service_check is the daily-check entry point. For g-daan it also rewrites the aircraft’s identity: H70/group 70 when g-daas is in service, otherwise H71/group 71 (and it clears in_use in that branch). It then returns unavailable_due_to_service, a linear scan of the resource’s own schedule that treats the window as inclusive at both ends. hems_resource_on_shift picks summer or winter hours by month and delegates to Utils.is_time_in_range; the comment there warns that changing the summer definition also means editing write_run_params() in des_parallel_process.py.
Building the resources
The constructor of HEMSAvailability creates both FilterStores and calls the preparation methods:
air_ambulance_des/class_hems_availability.py:96-123
# School holidays
self.school_holidays = load_school_holidays("actual_data/school_holidays.csv")
self.HEMS_resources_list = []
self.active_callsign_groups = (
set()
) # Prevents same crew being used twice...hopefully...
self.active_registrations = set() # Prevents same vehicle being used twice
self.active_callsigns = set()
# Create a store for HEMS resources
self.store = FilterStore(env)
self.serviceStore = FilterStore(env)
# Prepare HEMS resources for ingesting into store
self.prep_HEMS_resources()
# Populate the store with HEMS resources
self.populate_store()
# Daily servicing check (in case sim starts during a service)
[dow, hod, weekday, month, qtr, current_dt] = (
self.utilityClass.date_time_of_call(self.sim_start_date, self.env.now)
)
self.daily_servicing_check(current_dt, hod, month)sim_end_date is the sim end plus a year, because service dates are walked backwards and can land in the previous year. The three active_* sets are used by allocation (Chapter 6). Lines 108 and 110 create the two stores; line 113 (self.prep_HEMS_resources()) builds the schedules.
prep_HEMS_resources first merges three CSVs into one table: service_schedules_by_model.csv (interval in months, duration in weeks), callsign_registration_lookup.csv (which registration is which callsign and model) and service_history.csv (last service date). It then walks each aircraft forward from its last service until it passes sim_end_date:
air_ambulance_des/class_hems_availability.py:245-288
for index, row in SERVICING_SCHEDULE.iterrows():
# self.debug(row)
current_resource_service_dates = []
# Check if service date provided
if not pd.isna(row["last_service"]):
# self.debug(f"Checking {row['registration']} with previous service date of {row['last_service']}")
last_service = datetime.strptime(row["last_service"], "%Y-%m-%d")
service_date = last_service
while last_service < self.sim_end_date:
end_date = (
last_service
+ timedelta(weeks=int(row["service_duration_weeks"]))
+ timedelta(weeks=self.serviing_buffer_weeks)
)
service_date, end_date = self.find_next_service_date(
last_service,
row["service_schedule_months"],
service_dates,
row["service_duration_weeks"],
)
schedule.append((row["registration"], service_date, end_date))
# self.debug(service_date)
service_dates.append(
{
"service_start_date": service_date,
"service_end_date": end_date,
}
)
current_resource_service_dates.append(
{
"service_start_date": service_date,
"service_end_date": end_date,
}
)
# self.debug(service_dates)
# self.debug(current_resource_service_dates)
last_service = service_date
else:
schedule.append((row["registration"], None, None))
# self.debug(schedule)The shared service_dates list (the service_dates.append(...) call at lines 270-282 adds to it) accumulates the windows of every resource processed so far; that is what lets find_next_service_date keep aircraft out of the workshop simultaneously. Order therefore matters: the first row in the merged table gets first pick of dates. Cars have last_service of 0, which na_values=0 turns into NaN, so they take the else branch with a None window. The end_date computed at the top of the loop (with the buffer weeks) is immediately overwritten by the find_next_service_date call at line 261, so only the second value is ever used.
Then each HEMS_ROTA.csv row is merged with the servicing table on callsign and vehicle_type and turned into an object:
air_ambulance_des/class_hems_availability.py:299-325
HEMS_RESOURCES = (
pd.read_csv("actual_data/HEMS_ROTA.csv")
# Add model and servicing rules
.merge(SERVICING_SCHEDULE, on=["callsign", "vehicle_type"], how="left")
)
for index, row in HEMS_RESOURCES.iterrows():
s = service_df[service_df["registration"] == row["registration"]]
# self.debug(s)
# Create new HEMS resource and add to HEMS_resource_list
# pd.DataFrame(columns=['year', 'service_start_date', 'service_end_date'])
hems = HEMS(
callsign=row["callsign"],
callsign_group=row["callsign_group"],
vehicle_type=row["vehicle_type"],
category=row["category"],
registration=row["registration"],
summer_start=row["summer_start"],
winter_start=row["winter_start"],
summer_end=row["summer_end"],
winter_end=row["winter_end"],
servicing_schedule=s,
resource_id=row["registration"],
)
self.HEMS_resources_list.append(hems)Each rota row becomes one HEMS. H70 appears twice in the rota (a CC row and an EC row, with different hours) but shares a registration, so both objects get the same servicing slice. populate_store then simply puts each object into store.
Finding a service date
find_next_service_date is the scheduling core:
air_ambulance_des/class_hems_availability.py:762-793
next_due_date = last_service_date + relativedelta(
months=interval_months
) # Approximate month length
end_date = next_due_date + timedelta(weeks=service_duration)
preferred_date = datetime(next_due_date.year, self.servicing_preferred_month, 2)
preferred_end_date = preferred_date + timedelta(weeks=service_duration)
if next_due_date.month > preferred_date.month:
preferred_date += relativedelta(years=1)
# 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}")
# If preferred date is valid, use it
if preferred_date <= next_due_date and not self.is_during_school_holidays(
preferred_date, preferred_end_date
):
next_due_date = preferred_date
while True:
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
):
next_due_date -= timedelta(days=1)
end_date = next_due_date + timedelta(weeks=service_duration)
continue
else:
break
return [next_due_date, end_date]The due date is last_service + interval_months. If the preferred date (day 2 of servicing_preferred_month in the due year) is on or before the due date and clear of school holidays, it replaces the due date. Then the while True loop steps one day earlier (line 787) until the window clears both school holidays (is_during_school_holidays) and every window already in service_dates (is_other_resource_being_serviced), both built on do_ranges_overlap. Services therefore only ever move earlier than due, never later.
School holidays come from load_school_holidays("actual_data/school_holidays.csv"), called in the HEMSAvailability constructor. It reads the CSV and passes it through parse_school_holidays, which converts both columns to timestamps (UK day-first strings and ISO strings are both accepted, per tests/test_school_holidays.py) and raises ValueError if any holiday ends before it starts.
The daily servicing check
DES_HEMS.generate_calls runs this on the first hour of each simulated day:
air_ambulance_des/des_hems.py:245-248
# Also run scripts to check HEMS resources to see whether they are starting/finishing service
yield self.env.process(
self.hems_resources.daily_servicing_check(current_dt, hod, month)
)The method is a generator, so it is run as a SimPy process and the day’s calls wait for it. It works in three passes:
air_ambulance_des/class_hems_availability.py:139-198
GDAAS_service = False
all_resources = self.serviceStore.items + self.store.items
for h in all_resources:
if h.registration == "g-daas":
GDAAS_service = h.unavailable_due_to_service(current_dt)
break
self.debug(f"GDAAS_service is {GDAAS_service}")
# --- Return from serviceStore to store ---
to_return = [
(s.category, s.registration)
for s in self.serviceStore.items
if not s.service_check(current_dt, GDAAS_service) # Note the NOT here!
]
# Attempted fix for gap after return from H70 duties
for h in self.store.items:
if h.registration == "g-daan" and not GDAAS_service:
h.callsign_group = 71
h.callsign = "H71"
if to_return:
self.debug("Service store has items to return")
for category, registration in to_return:
s = yield self.serviceStore.get(
lambda item: item.category == category
and item.registration == registration
)
yield self.store.put(s)
self.debug(
f"Returned [{s.category} / {s.registration}] from service to store"
)
# --- Send from store to serviceStore ---
to_service = [
(h.category, h.registration)
for h in self.store.items
if h.service_check(current_dt, GDAAS_service)
]
for category, registration in to_service:
self.debug("****************")
self.debug(
f"HEMS [{category} / {registration}] being serviced, removing from store"
)
h = yield self.store.get(
lambda item: item.category == category
and item.registration == registration
)
self.debug(
f"HEMS [{h.category} / {h.registration}] successfully removed from store"
)
yield self.serviceStore.put(h)
self.debug(f"HEMS [{h.category} / {h.registration}] moved to service store")
self.debug("***********")- Ask the
g-daasobject whether it is in service today. That boolean (GDAAS_service) is passed to everyservice_check, which is howg-daanlearns whether to be H70 or H71. - Return from
serviceStoretostoreeverything whoseservice_checkis now False (note thenotat line 153). Agetwith a lambda filter on category and registration pulls the exact object out, thenstore.put. - Send to
serviceStoreeverything instorewhoseservice_checkis True (lines 176-180), using the same get/put pattern.
Because only store.items are checked, a resource out on a job is skipped and cannot be taken for service until it comes back and a later daily check finds it. The g-daan fix-up loop between the two passes (“Attempted fix for gap after return from H70 duties”, in the source) forces idle g-daan objects back to H71 when g-daas is not in service.
sequenceDiagram
participant GC as generate_calls
participant HA as HEMSAvailability
participant H as HEMS objects
participant SS as serviceStore
participant S as store
GC->>HA: env.process(daily_servicing_check)
HA->>H: g-daas.unavailable_due_to_service(dt)
HA->>H: service_check(dt, GDAAS_service) for serviceStore items
HA->>SS: get, then S.put (returns)
HA->>H: service_check(dt, GDAAS_service) for store items
HA->>S: get, then SS.put (departures)
HA-->>GC: process ends, calls generated
Extending this
Add or change a vehicle. No code change is needed for the common cases: edit actual_data/HEMS_ROTA.csv (one row per callsign/category/shift pattern), callsign_registration_lookup.csv and, for aircraft, service_schedules_by_model.csv and service_history.csv. The app already writes these files (Chapter 1). HEMSAvailability.add_hems is an empty stub marked “future function” in its docstring, so it is not an extension point yet.
Change what “summer” means. rota_start_end_months.csv is read in HEMS.__init__. The comment in hems_resource_on_shift says write_run_params() in des_parallel_process.py must change too, otherwise run_params_used.csv will misreport what ran.
Change the scheduling rules. The knobs are servicing_overlap_allowed, servicing_buffer_weeks and servicing_preferred_month on HEMSAvailability.__init__, but DES_HEMS constructs it without them, so the defaults (False, 4, 1) always apply. Two things to know before relying on them: servicing_overlap_allowed is stored but never read in the code shown above, and the buffer attribute is spelled serviing_buffer_weeks. Changes to servicing also affect the availability figures used for utilisation in Chapter 7, so re-check those.
Add another swap like g-daan/g-daas. The swap is hard-coded by registration string in two places (daily_servicing_check and HEMS.service_check), plus the fix-up loop. A general mechanism does not exist.
Tests (see tests/README.md):
tests/test_school_holidays.py(quick): date parsing,ValueErroron inverted holidays, and plausibility of the holidays in every parameter file.tests/test_unittest_model.py: includestest_no_response_during_off_shift_timesandtest_no_response_during_service, which check that nothing is allocated off shift or in service.tests/test_against_condition_changes.py: behaviour under changed conditions.
Example usage (see tests/README.md)
pytest tests/test_school_holidays.py
pytest tests/test_unittest_model.py -k "service or shift"Run from the repository root (the tests and the model both use relative actual_data/ paths). Conventions: CSV column names are read by string key (row["summer_start"] and so on), so a renamed column fails with a KeyError when the model is built.
Sharp edges
- The constructor’s servicing check is a no-op.
HEMSAvailability.__init__callsself.daily_servicing_check(...)(line 123) without wrapping it inenv.process. The method containsyield, so the call only creates a generator that is never run. In practice the first day’s check fromgenerate_callsdoes the job, becausenew_daystarts at 1900-01-01. This reading comes from the code, not from a documented decision. - The search for a service date has no exit. The
while Trueinfind_next_service_dateonly steps backwards. If every earlier day collides, or the school holiday data is unusable, it never returns; this matches open issue #233 (open as of 2026-10-19), “Model hangs indefinitely if servicing schedule can’t be resolved”. In a parallel run that hangs one worker. - Process-wide state in files.
prep_HEMS_resourceswritesdata/service_dates.csvon every pass of the row loop, so concurrent replications overwrite one another’s copy. The content should be identical across seeds (nothing in scheduling is random), but treat it as a debugging artefact rather than an output. - Working directory matters. Every CSV path is relative. Run from the repository root.
in_useis reset in two places and not always.service_checkclears it forg-daanonly in the “not in service” branch;return_resourceclears it otherwise (Chapter 6).- Cost.
unavailable_due_to_serviceusesiterrows, andis_during_school_holidaysloops over every holiday row for each candidate day. It only matters at setup and in the daily check, but a long backwards search multiplies it. - Busy resources defer servicing. A resource on a job at the day boundary is not in
store.items, so entry to the workshop is delayed to a later daily check (derived from howFilterStoreremoves items; no test intests/exercises it).
Design notes
Documented, from the project’s commit history:
Ambulancewas added first (5ba60b0, 2024-09-13) as the generic responder thatHEMSinherits from.- Servicing was scheduled around school holidays and a preferred month in
3242c69(2025-02-13), and separated from the rota file inba0f813(2025-03-21), which is why servicing has its own three CSVs. - The
g-daanto H70 re-allocation was corrected ind9a0e58(2025-04-04), after a first attempt (be8e91d) was noted as not working. - The daily check was refactored in
b08d224and a fix for simultaneous allocation of the same resource landed in8f42554(both 2025-04-24); theactive_*sets in this chapter’s constructor are part of that story. - Rota start/end checks moved from quarters to selected months in
7b88b4a(2025-05-21), explaining whyHEMSreads month numbers. - School holiday parsing was made more robust in
cfefa7e(2026-10-15), which is whenschool_holidays.pyappears in history.
Inferred:
- Two stores rather than a flag on each resource: the code suggests this keeps allocation’s
store.getfilters free of servicing logic, since an aircraft in the workshop is simply not there. - Scheduling in advance for the whole horizon, rather than checking servicing as the run proceeds, presumably so the placement can avoid collisions across aircraft, which requires seeing everyone’s windows.
- The extra year on
sim_end_dateis explained by its own comment: dates can be walked back into the previous year.
Limitations and open questions
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.
There are no TODO/FIXME markers in these files. Open issues, as of 2026-10-19:
- #233: the model hangs if the servicing schedule can’t be resolved.
- #220: how to force winter servicing when a helicopter has time off.
- #266: whether the H145/H135 service interval assumptions hold with two H145s.
- #92 and #143: servicing overruns and the “changing helicopter model” scenario are under-documented.
- #60: a decision is pending on periods of extreme unavailability.
Check the issue tracker for the current state.
Conclusion
With resources built and shuffled between stores, Resource Allocation shows how HEMSAvailability picks one for each patient and releases it afterwards.