flowchart TD
run["DES_HEMS.run()"] --> gc["generate_calls()<br/>one process per run"]
gc -->|"once per call"| gp["generate_patient()<br/>builds a Patient"]
gp -->|"allocation (chapter 6)"| pj["patient_journey()<br/>one process per patient"]
gp --> log[("results_list<br/>event log")]
pj --> log
Chapter 4: Calls and Patient Journeys
In Sampling Distributions and Random Streams we saw how Utils hands out single sampled values from named, independently seeded random streams. This chapter is where those values get used. We follow one simulated job from the moment the day’s call count is drawn to the moment the final event-log row is written. Choosing which helicopter or car attends is treated as a black box here; it is the subject of Resource Allocation.
A day in the life of one call
Suppose you run the model for a week starting on 1 January. On the first morning the model needs to answer a chain of questions:
- How many calls will there be today?
- In which hours do they fall?
- For each call, who is the patient, how ill are they, and would a helicopter help?
- Once a vehicle is found, how long does each stage of the job take, and what do we record along the way?
DES_HEMS answers all four. It owns the SimPy environment for a single run (the one built and started by runSim, see Simulation Runs and Replications), and its methods fall into three layers:
- a call generator (
generate_calls) that runs once per run and creates arrivals; - a patient creator (
generate_patient) that runs once per call and builds aPatient; - a patient journey (
patient_journey) that runs once per HEMS call, whether or not a resource was found, and walks through the job stages.
Everything they observe is appended to the event log, which becomes output_run_N.csv and eventually run_results.csv. The project overview lists the model logic sequence and output columns; this chapter explains the code that produces them.
Event log: a list of dictionaries, one per step per patient, turned into a DataFrame at the end of the run. Each row has an event_type and a time_type.
A SimPy process is a Python generator. Each yield env.timeout(n) pauses it for n simulated minutes, and env.process(gen) registers a new generator to run alongside the others. The model’s unit of time is the minute. yielding another process, or a resource request, pauses the caller until that finishes.
Setting up the environment
The constructor creates the pieces that the rest of the chapter relies on.
air_ambulance_des/des_hems.py:56-98
self.run_number = run_number + 1 # Add 1 so we don't have a run_number 0
self.sim_duration = sim_duration
self.warm_up_duration = warm_up_duration
self.sim_start_date = sim_start_date
self.random_seed = random_seed
self.random_seed_sequence = SeedSequence(self.random_seed)
self.print_debug_messages = print_debug_messages
self.utils = Utils(
master_seed=self.random_seed_sequence.spawn(1)[0],
print_debug_messages=self.print_debug_messages,
)
self.utils.setup_seeds()
self.demand_increase_percent = demand_increase_percent
# Option for sampling calls per day by season or quarter
self.daily_calls_by_quarter_or_season = "quarter"
# Option to include/exclude ambulance service cases in addition to HEMS
self.amb_data = amb_data
# self.debug(f"Ambulance data values is {self.amb_data}")
self.all_results_location = self.utils.ALL_RESULTS_CSV
self.run_results_location = self.utils.RUN_RESULTS_CSV
self.env = simpy.Environment()
self.patient_counter = 0
self.calls_today = 0
self.new_day = pd.to_datetime("1900-01-01").date
self.new_hour = -1
self.hems_resources = HEMSAvailability(
env=self.env,
sim_start_date=sim_start_date,
sim_duration=sim_duration,
print_debug_messages=self.print_debug_messages,
master_seed=self.random_seed_sequence,
utility=self.utils,
)The SeedSequence and the Utils object come from the previous chapter. The highlighted lines are the new ones: a fresh simpy.Environment() (the SimPy environment, the clock and scheduler for this run) and the HEMSAvailability object that will hold the helicopters and cars. Note that the constructor adds one to run_number so that runs are numbered from 1.
Generating the day’s calls
Calls per day and per hour
generate_calls loops until the simulation clock reaches sim_duration + warm_up_duration. At the top of each iteration it asks Utils.date_time_of_call to convert the minute counter into a calendar date, day of week, hour, month and quarter. When the date has changed from the last one it saw, it starts a new day.
air_ambulance_des/des_hems.py:206-248
while self.env.now < (self.sim_duration + self.warm_up_duration):
# Get current day of week and hour of day
[dow, hod, weekday, month, qtr, current_dt] = self.utils.date_time_of_call(
self.sim_start_date, self.env.now
)
if self.new_hour != hod:
self.new_hour = hod
# self.debug("new hour")
# If it is a new day, need to calculate how many calls
# in the next 24 hours
if self.new_day != current_dt.date():
# self.debug("It's a new day")
# self.debug(dow)
# self.debug(f"{self.new_day} and {current_dt.date}")
# Now have additional option of determining calls per day by quarter instead of season
# self.calls_today = int(self.utils.inc_per_day(qtr, self.daily_calls_by_quarter_or_season) * (self.demand_increase_percent))
# Try with actual sampled values instead
self.calls_today = int(
self.utils.inc_per_day_samples(
qtr, self.daily_calls_by_quarter_or_season
)
* (self.demand_increase_percent)
)
# self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today")
# self.debug(f"{current_dt.date()} There will be {self.calls_today} calls today")
self.new_day = current_dt.date()
ia_dict = {}
ia_dict = self.calls_per_hour(qtr)
# self.debug(ia_dict)
# 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 highlighted lines do the real work of a new day. inc_per_day_samples draws one historical daily call count for the current quarter (a calls per day sample, see the previous chapter), which is then multiplied by demand_increase_percent, the demand multiplier set in the app, and truncated to an integer. Then calls_per_hour spreads those calls over the day, and the daily servicing check runs as a sub-process (yield self.env.process(...)) so that resources due for servicing are moved out of circulation before any call is allocated. That check is covered in HEMS Resources and Servicing.
calls_per_hour is where the hourly arrival profile is used:
air_ambulance_des/des_hems.py:126-161
def calls_per_hour(self, quarter: int) -> dict:
"""
Function to return a dictionary of keys representing the hour of day
and value representing the number of calls in that hour
"""
# self.debug(f"There are going to be {self.calls_today} calls today and the current hour is {current_hour}")
hourly_activity = self.utils.hourly_arrival_by_qtr_probs_df
hourly_activity_for_qtr = hourly_activity[
hourly_activity["quarter"] == quarter
][["hour", "proportion"]]
calls_in_hours = []
for i in range(0, self.calls_today):
hour = pd.Series.sample(
hourly_activity_for_qtr["hour"],
weights=hourly_activity_for_qtr["proportion"],
random_state=self.utils.rngs["calls_per_hour"],
).iloc[0]
# self.debug(f"Chosen hour is {hour}")
calls_in_hours.append(hour)
calls_in_hours.sort()
# self.debug(calls_in_hours)
d = {}
hours, counts = np.unique(calls_in_hours, return_counts=True)
for i in range(len(hours)):
d[hours[i]] = counts[i]
return dFor each of today’s calls it samples an hour of the day, weighting each hour by its historical proportion for this quarter, using the calls_per_hour random stream. The result is a dictionary such as {3: 1, 9: 2, 14: 1}: one call in hour 3, two in hour 9 and one in hour 14. Because every call has its own sampled hour, busy hours are likely to get several calls and quiet hours none.
predetermine_call_arrival (lines 163-194 of the same file) also turns the hourly profile into inter-arrival times using an exponential distribution, and it has its own named stream ("predetermine_call_arrival") set up in Utils. However, nothing in the package calls it, and generate_calls uses the simpler uniform approach described below. Treat it as an unused alternative.
Spacing calls within an hour
Having decided how many calls fall in each hour, the generator walks through the day hour by hour.
air_ambulance_des/des_hems.py:250-284
if self.calls_today > 0:
if hod in ia_dict.keys():
minutes_elapsed = 0
for i in range(0, ia_dict[hod]):
if minutes_elapsed < 59:
# Determine remaining time and sample a wait time
remaining_minutes = 59 - minutes_elapsed
# wait_time = random.randint(0, remaining_minutes)
wait_time = int(
self.utils.rngs["call_iat"].integers(
0, remaining_minutes + 1
)
)
yield self.env.timeout(wait_time)
minutes_elapsed += wait_time
self.env.process(
self.generate_patient(
dow, hod, weekday, month, qtr, current_dt
)
)
[dow, hod, weekday, month, qtr, current_dt] = (
self.utils.date_time_of_call(
self.sim_start_date, self.env.now
)
)
next_hr = current_dt.floor("h") + pd.Timedelta("1h")
yield self.env.timeout(
math.ceil(
pd.to_timedelta(next_hr - current_dt).total_seconds() / 60
)
)For each call in the current hour, the generator samples a wait of between 0 and the remaining minutes of the hour (the call_iat stream), pauses for that long, and then starts generate_patient as an independent SimPy process with env.process. Starting it as a new process matters: the generator does not wait for the patient to finish, so many patients can be mid-journey while new calls keep arriving. After the last call in the hour, the generator sleeps until the top of the next hour. The matching else branch (days with zero calls) skips straight to midnight.
Creating the patient
Each call becomes a Patient, a plain object of attributes defined in class_patient.py: id, age, sex, AMPDS card, care category, outcome, the preferred vehicle, and the details of whichever vehicle is eventually allocated. generate_patient fills in the first group by sampling.
air_ambulance_des/des_hems.py:303-335
def generate_patient(self, dow, hod, weekday, month, qtr, current_dt):
self.patient_counter += 1
# Create a new caller/patient
pt = Patient(self.patient_counter)
# Update patient instance with time-based values so the current time is known
pt.day = dow
pt.hour = hod
pt.weekday = weekday
pt.month = month
pt.qtr = qtr
pt.current_dt = current_dt
# self.debug(f"Patient {pt.id} incident date: {pt.current_dt}")
# Update patient instance with age, sex, AMPDS card, whether they are a HEMS' patient and if so, the HEMS result,
pt.ampds_card = self.utils.ampds_code_selection(pt.hour)
# self.debug(f"AMPDS card is {pt.ampds_card}")
pt.age = self.utils.age_sampling(pt.ampds_card, 115)
pt.sex = self.utils.sex_selection(pt.ampds_card)
hems_cc_or_ec = self.utils.care_category_selection(pt.ampds_card)
pt.hems_cc_or_ec = hems_cc_or_ec
self.debug(
f"{pt.current_dt}: {pt.id} Pt allocated to {pt.hems_cc_or_ec} from AMPDS {pt.ampds_card}"
)
pt.pt_outcome = self.utils.pt_outcome_selection(pt.hems_cc_or_ec, int(qtr))
self.debug(
f"{pt.current_dt}: {pt.id} Pt allocated to patient outcome: {pt.pt_outcome}"
)
self.add_patient_result_row(pt, "arrival", "arrival_departure")The highlighted lines each call a Utils selection method, so each draws from its own named random stream:
| Attribute | Sampled by | Meaning |
|---|---|---|
ampds_card |
ampds_code_selection(hour) |
the AMPDS card, the dispatch triage code for the call, dependent on the hour |
age, sex |
age_sampling, sex_selection |
demographics, conditional on the card |
hems_cc_or_ec |
care_category_selection(card) |
the care category: CC (critical care), EC (enhanced care) or REG (regular) |
pt_outcome |
pt_outcome_selection(category, qtr) |
the patient outcome, e.g. deceased or injured, which later drives the HEMS result |
AMPDS card: the triage code assigned to a call by the dispatch system. It drives the sampling of age, sex and care category.
The first event-log row is written straight away: add_patient_result_row(pt, "arrival", "arrival_departure"). The time_type is “arrival” and the event_type is arrival_departure, the label the results code later uses to mark entry to and exit from the system.
Helicopter benefit and preferences
Next comes the part that decides who is wanted. For HEMS cases the model samples a preferred callsign group, then a helicopter benefit flag: whether this job is one where a helicopter specifically would help. The probability depends on the care category and is read from a small text file written by the distribution-fitting step.
air_ambulance_des/des_hems.py:360-371
# Update for REG patients based on historically observed patterns
with open(
"distribution_data/proportion_jobs_heli_benefit.txt", "r"
) as file:
expected_prop_heli_benefit_jobs_reg = float(file.read().strip())
if pt.hems_cc_or_ec == "REG":
helicopter_benefit = (
"y"
if self.utils.rngs["helicopter_benefit_from_reg"].uniform(0, 1)
<= expected_prop_heli_benefit_jobs_reg
else "n"
)Here the REG case is shown; CC and EC follow the same pattern with their own file and random stream. The flag defaults to "n" and becomes "y" when a uniform draw falls at or below the historical proportion. The results are logged as patient_care_category and patient_helicopter_benefit rows, followed by the preferred callsign group and vehicle type (resource_preferred_resource_group and resource_preferred_vehicle_type).
Just after those three blocks, generate_patient tests if pt.hems_result == "Patient Conveyed by HEMS" and sets the benefit to "y". At that point in the code, pt.hems_result has only its initial value of "", because the HEMS result is sampled later in patient_journey. The condition therefore never holds, and a comment in the source says the rule was parked while the team decides how to link the two. Also, the amb_data branch (pt.hems_case == 0 as the “else” value) looks like a comparison where an assignment was intended. (The expression evaluates to False, which later behaves like 0, so with amb_data on the other half of patients simply get no HEMS journey; the code works by accident of Python’s False == 0.) Here amb_data is a flag for including non-HEMS ambulance-service cases, normally off. With it off, which is the normal setting, neither matters.
Hand-off to resource allocation
air_ambulance_des/des_hems.py:432-466
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)
self.debug(f"{pt.id} hems_res_list: {hems_res_list}")
hems_allocation = hems_res_list[0]
# This will either contain the other resource in a callsign_group and HEMS category (EC/CC) or None
hems_group_resource_allocation = hems_res_list[2]
self.add_patient_result_row(
pt, hems_res_list[1], "resource_preferred_outcome"
)
if hems_allocation is not None:
# self.debug(f"allocated {hems_allocation.callsign}")
self.env.process(
self.patient_journey(
hems_allocation, pt, hems_group_resource_allocation
)
)
else:
# self.debug(f"{pt.current_dt} No HEMS resource available - non-DAAT land crew sent")
self.env.process(self.patient_journey(None, pt, None))CC and EC patients only go down the “known need” route about half the time (a know_cc_ec_benefit draw of 0.5 or less); all others go through allocate_regular_resource. Either way, yield pauses this patient until the allocation event resolves, and returns a three-item list: the primary resource, a text reason, and an optional secondary resource. How those are chosen is the black box of Resource Allocation. From here we only need two facts: the reason is logged as resource_preferred_outcome, and the primary may be None, meaning nothing was available. In both cases a patient_journey process is started.
Moving through the stages
patient_journey is the longest method in the file but has a simple shape: for each stage, sample a duration, log it, and yield env.timeout(duration).
flowchart LR
A["arrival<br/>(arrival_departure)"] --> B["allocation<br/>(time_allocation)"]
B --> C["mobile<br/>(time_mobile)"]
C --> D["to scene<br/>(time_to_scene)"]
D --> E["on scene<br/>(time_on_scene)"]
E --> F["to hospital<br/>(time_to_hospital)"]
F --> G["clear<br/>(time_to_clear)"]
G --> H["depart<br/>(arrival_departure)"]
C -. "Stand Down" .-> G
E -. "no patient conveyed" .-> G
Each box carries the event_type of its duration row; a second row with event_type queue and a descriptive time_type (for example “HEMS mobile”) marks the stage change itself. The dotted lines show shortcuts, described below.
No vehicle available
If the primary resource is None, the journey is almost empty:
air_ambulance_des/des_hems.py:489-497
hems_case = True if patient.hems_case == 1 else False
hems_avail = True if hems_res is not None else False
if not hems_avail:
self.debug(f"Patient {patient.id}: No HEMS available")
self.add_patient_result_row(patient, "No HEMS available", "queue")
self.add_patient_result_row(
patient, "No Resource Available", "resource_request_outcome"
)Two rows are logged (queue with “No HEMS available”, and resource_request_outcome with “No Resource Available”). Every later stage is guarded by hems_case and hems_avail (hems_case is true for jobs the model treats as HEMS jobs, normally all of them, which is reasonable because the demand being modelled is what the HEMS desk recorded, including jobs reviewed afterwards that arrived while no vehicle was staffed, not an estimate of every emergency a helicopter might attend; and hems_avail is true when a vehicle was allocated), so nothing else is added except the final depart row. These are the rows that later chapters count as missed jobs.
With a vehicle: HEMS result and stage shortcuts
When a vehicle is available, patient_journey copies its vehicle type, registration and callsign onto the patient and logs them (selected_vehicle_type, selected_callsign_group, resource_use, callsign_group_resource_use). It then samples the HEMS result, the historical outcome of such a tasking.
air_ambulance_des/des_hems.py:519-559
if hems_res:
patient.hems_result = self.utils.hems_results_by_patient_outcome_and_time_of_day_and_quarter_and_vehicle_type_and_callsign_group_probs(
patient.pt_outcome,
int(patient.qtr),
patient.hems_vehicle_type,
patient.hems_callsign_group,
int(patient.hour),
)
else:
# Default to what is recorded when no resource sent
patient.hems_result = "Unknown"
self.debug(
f"{patient.current_dt}: PT_ID:{patient.id} Pt allocated to HEMS result: {patient.hems_result}"
)
self.add_patient_result_row(
patient, "HEMS Resource Available", "resource_request_outcome"
)
self.add_patient_result_row(patient, hems_res.callsign, "resource_use")
self.debug(
f"{patient.current_dt} Patient {patient.id} (preferred callsign group {patient.hems_pref_callsign_group}, preferred resource type {patient.hems_pref_vehicle_type}) sent resource {hems_res.callsign}"
)
self.add_patient_result_row(
patient, hems_res.callsign, "callsign_group_resource_use"
)
# Check if HEMS result indicates that resource stood down before going mobile or en route
no_HEMS_at_scene = (
True if patient.hems_result in ["Stand Down"] else False
)
# Check if HEMS result indicates no leaving scene/at hospital times
no_HEMS_hospital = (
True
if patient.hems_result
in [
"Stand Down",
"Landed but no patient contact",
"Patient Treated but not conveyed by HEMS",
]
else False
)The result depends on the patient outcome, quarter, hour, vehicle type and callsign group, so a car in the evening has a different mix of results from a helicopter at noon. The two flags at the bottom turn it into stage shortcuts. A Stand Down result means the crew was activated and then cancelled before reaching the scene (no_HEMS_at_scene), so the to-scene and on-scene stages are skipped. “Landed but no patient contact”, “Patient Treated but not conveyed by HEMS” and “Stand Down” all mean nobody travels to hospital (no_HEMS_hospital), so the to-hospital stage is skipped. These are the dotted arrows in Figure 2.
One stage in detail
All stages repeat the same pattern. This is the allocation stage:
air_ambulance_des/des_hems.py:577-588
if hems_case and hems_avail:
# Calculate min and max permitted times.
allocation_time = (
self.utils.activity_time(
patient.hems_vehicle_type, "time_allocation"
)
* self.activity_duration_multiplier
)
self.add_patient_result_row(patient, allocation_time, "time_allocation")
self.add_patient_result_row(patient, "HEMS allocated to call", "queue")
# self.debug(f"Vehicle type {patient.hems_vehicle_type} and allocation time is {allocation_time}")
yield self.env.timeout(allocation_time)Utils.activity_time(vehicle_type, "time_allocation") draws a duration (in minutes) from the fitted distribution for that vehicle type, which also respects the upper allowable bound described in the previous chapter. It is then multiplied by activity_duration_multiplier, the scenario setting that makes all jobs proportionally longer or shorter. The duration itself is logged as the time_type of a row whose event_type is "time_allocation", a queue row marks the new state, and the yield advances the clock. Mobile, to scene, on scene, to hospital and clear differ only in their names and guards (lines 599-706).
Returning the resource
air_ambulance_des/des_hems.py:711-721
self.add_patient_result_row(patient, "depart", "arrival_departure")
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"
)The whole journey sits inside a try and this finally block. The final depart row is written first, and then, whatever happens, a non-None primary resource and its secondary go back to the store through return_resource, and a resource_use_end row is logged. Using finally guarantees the vehicle is released even if the simulation is interrupted, so a crew cannot be lost from the pool.
Writing the event log
Every add_patient_result_row call above ends up here:
air_ambulance_des/des_hems.py:723-765
def add_patient_result_row(
self, patient: Patient, time_type: str, event_type: str, **kwargs
) -> None:
"""
Convenience function to create a row of data for the results table
"""
results = {
"P_ID": patient.id,
"run_number": self.run_number,
"time_type": time_type, # e.g. mobile, at scene, leaving scene etc.
"event_type": event_type, # for animation: arrival_departure, queue, resource_use, resource_use_end
"timestamp": self.env.now,
"timestamp_dt": self.sim_start_date + timedelta(minutes=self.env.now),
"day": patient.day,
"hour": patient.hour,
"weekday": patient.weekday,
"month": patient.month,
"qtr": patient.qtr,
"registration": patient.hems_registration, # registration of allocated/attending vehicle
"callsign": patient.callsign, # callsign of allocated/attending vehicle
"callsign_group": patient.hems_callsign_group, # callsign group of allocated/attending vehicle
"vehicle_type": patient.hems_vehicle_type, # vehicle type (car/helicopter) of allocated/attending vehicle
"hems_res_category": patient.hems_category,
"ampds_card": patient.ampds_card,
"age": patient.age,
"sex": patient.sex,
"care_cat": patient.hems_cc_or_ec,
"heli_benefit": patient.hems_helicopter_benefit,
"hems_result": patient.hems_result,
"outcome": patient.pt_outcome,
"hems_reg": patient.hems_registration,
}
# self.debug(results)
# Add any additional items passed in **kwargs
for key, value in kwargs.items():
results[key] = value
if self.env.now >= self.warm_up_duration:
self.store_patient_results(results)A row is a dictionary snapshot of the patient plus the clock: patient id, run number, the simulation minute (timestamp) and the calendar date and time (timestamp_dt), then the patient’s current attributes and any extra keyword arguments. Two columns carry the meaning of the row:
event_typesays what kind of event this is. The source comment describes it as the label for animation:arrival_departure,queue,resource_use,resource_use_end, and for this model, thetime_*,patient_*andresource_*labels seen above.time_typesays which value this is, such as “arrival”, a stage label like “HEMS mobile”, or a number of minutes for a duration row.
The last two lines are the warm-up rule from Simulation Runs and Replications: a row is only stored when env.now >= warm_up_duration, so events during warm-up still change the model’s state but leave no trace in the output. Patient-level attributes are repeated on every row, so the log is long and denormalised rather than one row per patient.
A shortened, hypothetical set of rows for one patient looks like this:
Example output (columns trimmed)
P_ID time_type event_type timestamp callsign
12 arrival arrival_departure 1503.0
12 CC patient_care_category 1503.0
12 HEMS call start queue 1503.0 H70
12 7.4 time_allocation 1503.0 H70
12 HEMS allocated to call queue 1503.0 H70
12 HEMS mobile queue 1510.4 H70
...
12 depart arrival_departure 1642.9 H70
12 H70 resource_use_end 1642.9 H70The values here are invented to show the structure, not output from a real run. Note the 7.4 row: for duration rows the number of minutes is stored in the time_type column. At the end of the run, run() converts results_list into a DataFrame, appends it to the shared all-results file and writes this run’s output_run_N.csv; the next chapters pick the data up from there, and Trial Results and Historic Comparison shows how it is analysed.
How it all fits together
sequenceDiagram
participant GC as generate_calls
participant U as Utils
participant GP as generate_patient
participant HA as HEMSAvailability
participant PJ as patient_journey
participant L as Event log
GC->>U: inc_per_day_samples(qtr)
GC->>HA: daily_servicing_check
GC->>GP: env.process (per call)
GP->>U: sample card, age, sex, care category
GP->>L: arrival row
GP->>HA: allocate_resource / allocate_regular_resource
HA-->>GP: primary, reason, secondary
GP->>PJ: env.process
PJ->>U: activity_time per stage
PJ->>L: one row per stage
PJ->>HA: return_resource (finally)
Read it top to bottom: the generator decides how many calls to make and starts one generate_patient process per call. Each of these samples the patient, logs, waits for the allocation to resolve, then starts a patient_journey. The journey consumes the simulated minutes, logs each stage, and returns the vehicle.
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. When you meet a simplification in this chapter (such as treating every call as a HEMS job, or drawing daily call counts from past days), ask whether it is reasonable for the questions the model is meant to answer, not whether it is a perfect reflection of reality.
Conclusion
DES_HEMS turns historical distributions into a living stream of jobs. generate_calls draws a daily call count, scales it by the demand multiplier, spreads it over hours and starts a generate_patient process per call. That process samples AMPDS card, demographics, care category, outcome and helicopter benefit, asks for a vehicle, and hands the result to patient_journey, which steps through allocation, mobile, scene, hospital and clear, shortening itself according to the sampled HEMS result. Every step is appended to the event log by add_patient_result_row, unless the warm-up period is still running.
We have repeatedly relied on a store of vehicles without looking inside it. Next, in HEMS Resources and Servicing, we open that box and see how helicopters and cars are built from the rota files and kept in a SimPy FilterStore.