Chapter 5: HEMS Resources and Servicing

How helicopters and cars are modelled as HEMS objects, built from the rota and servicing CSVs, scheduled around school holidays and shuffled between a SimPy FilterStore and a service store.

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: store holds every resource, serviceStore is empty until the first daily check.
  • A resource that is in_use (out on a job) is not in the store. Anything iterating store.items only ever sees idle resources.
  • hems_resource_on_shift(hour, month) and unavailable_due_to_service(dt) are pure lookups; the second also sets being_serviced as 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.

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)
Figure 1: Where a HEMS object lives during a run

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_id

The 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("***********")
  1. Ask the g-daas object whether it is in service today. That boolean (GDAAS_service) is passed to every service_check, which is how g-daan learns whether to be H70 or H71.
  2. Return from serviceStore to store everything whose service_check is now False (note the not at line 153). A get with a lambda filter on category and registration pulls the exact object out, then store.put.
  3. Send to serviceStore everything in store whose service_check is 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
Figure 2: Daily servicing check, as called from the call generator

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, ValueError on inverted holidays, and plausibility of the holidays in every parameter file.
  • tests/test_unittest_model.py: includes test_no_response_during_off_shift_times and test_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__ calls self.daily_servicing_check(...) (line 123) without wrapping it in env.process. The method contains yield, so the call only creates a generator that is never run. In practice the first day’s check from generate_calls does the job, because new_day starts 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 True in find_next_service_date only 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_resources writes data/service_dates.csv on 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_use is reset in two places and not always. service_check clears it for g-daan only in the “not in service” branch; return_resource clears it otherwise (Chapter 6).
  • Cost. unavailable_due_to_service uses iterrows, and is_during_school_holidays loops 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 how FilterStore removes items; no test in tests/ exercises it).

Design notes

Documented, from the project’s commit history:

  • Ambulance was added first (5ba60b0, 2024-09-13) as the generic responder that HEMS inherits from.
  • Servicing was scheduled around school holidays and a preferred month in 3242c69 (2025-02-13), and separated from the rota file in ba0f813 (2025-03-21), which is why servicing has its own three CSVs.
  • The g-daan to H70 re-allocation was corrected in d9a0e58 (2025-04-04), after a first attempt (be8e91d) was noted as not working.
  • The daily check was refactored in b08d224 and a fix for simultaneous allocation of the same resource landed in 8f42554 (both 2025-04-24); the active_* 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 why HEMS reads month numbers.
  • School holiday parsing was made more robust in cfefa7e (2026-10-15), which is when school_holidays.py appears in history.

Inferred:

  • Two stores rather than a flag on each resource: the code suggests this keeps allocation’s store.get filters 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_date is explained by its own comment: dates can be walked back into the previous year.

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.

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.