flowchart LR
run["run()"] --> gc["generate_calls()<br/>one process"]
gc -->|"each day"| ds["daily_servicing_check()"]
gc -->|"each call"| gp["generate_patient()<br/>one process per call"]
gp -->|"yield"| alloc["allocate_resource() or<br/>allocate_regular_resource()"]
gp --> pj["patient_journey()<br/>one process per patient"]
pj --> log["add_patient_result_row()"]
gp --> log
pj -->|"finally"| ret["return_resource()"]
Chapter 4: Calls and Patient Journeys
Chapter 3 covered where the random numbers come from; this chapter covers what the model does with them.
What it is
DES_HEMS owns one run: one simpy.Environment, one Utils instance with its named random streams, one HEMSAvailability resource store and one list of event-log rows. Its constructor takes the run number, durations, start date, seed and the two multipliers that Chapter 2 passes in. All times are simulated minutes. A call to run() starts a single SimPy process, generate_calls(), and runs the environment until sim_duration + warm_up_duration.
The contract, from a caller’s point of view:
- In: the constructor arguments, plus the CSVs under
distribution_data/andactual_data/(viaUtilsandHEMSAvailability). - Out:
results_df, one row per step per patient, whichwrite_run_results()andwrite_all_results()persist (see Chapter 2). - Invariants you can rely on: every patient that starts a journey has the resource it was given returned in a
finallyblock; no row is stored beforewarm_up_duration; each patient has a uniqueidwithin the run.
Use case for this chapter: a CC (critical care) job (the other care categories are EC, enhanced care, and REG, regular) arrives at 14:20 on a weekday. We follow it from the daily call count, through the sampled patient attributes and the resource request, to the final depart row. Choosing the resource is Chapter 6; building the resources is Chapter 5. Here the allocation call is treated as a black box that yields a list.
Event log: the list of dicts built by add_patient_result_row(), turned into results_df at the end of run(). Each dict is one row of run_results.csv.
Figure 1 shows the shape: one long-lived generator process spawns a short-lived generate_patient process per call, and that in turn spawns a patient_journey process.
How it works
The call generator
generate_calls() is a loop that advances env.now by yielding timeouts. The first half of each iteration decides, once per simulated day, how many calls there will be.
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 daily count comes from Utils.inc_per_day_samples (a draw from the empirical daily call-count sample for the quarter, see Chapter 3), multiplied by demand_increase_percent and truncated with int(). calls_per_hour(qtr) then distributes those calls across the hours of the day. Finally the loop yields on daily_servicing_check, so servicing state is updated before any call that day. That ordering matters: a test named test_servicing_checked_on_days_with_no_calls (in tests/test_call_generation.py, see Extending this) exists because the check must run even when calls_today is zero.
ia_dict is the {hour: count} result of calls_per_hour; it is set once per day and consumed hour by hour in the second excerpt. calls_per_hour draws one hour per call, weighted by the quarter’s proportion column, using the calls_per_hour stream, and returns {hour: count}:
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 dpredetermine_call_arrival() (lines 163-194) also builds on calls_per_hour, but returns exponential inter-arrival times and nothing in generate_calls() calls it. The live path spreads each hour’s calls with uniform integer waits instead (next excerpt). A search of the package finds no other caller (check before deleting it), so treat it as an alternative arrival mechanism that is currently unused. The code is not documented as deprecated.
The second half of the iteration places the calls inside the current 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 an hour with n calls, each call waits a uniform integer number of minutes in [0, 59 - minutes_elapsed] (stream call_iat), then spawns generate_patient with env.process(...) (fire and forget; the generator does not wait for the patient). date_time_of_call is re-evaluated after each wait so the patient gets the true timestamp, not the hour start. After the loop the generator sleeps to the top of the next hour. The else branch (zero calls today) jumps straight to midnight.
Two properties follow from this scheme. Calls bunch towards the end of the hour: each wait is drawn from a window that shrinks as minutes_elapsed grows, so later calls in a busy hour cluster close to minute 59. The last call can never land after minute 59. A third consequence: once minutes_elapsed reaches 59 the if minutes_elapsed < 59 guard is false, so any remaining calls allocated to that hour by calls_per_hour are never generated, and the number of patients created can be lower than calls_today. This follows from reading the code; I have not measured how often it happens. These are modelling choices of this implementation, not properties of the historic data (inferred from the code; no commit or doc gives a reason).
Building a patient
generate_patient() is a generator function, so it can yield on the resource store. It starts by sampling the attributes that do not depend on resources.
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")Patient is a plain attribute bag (air_ambulance_des/class_patient.py, 78 lines) with defaults such as hems_cc_or_ec = "REG", hems_case = -1 and empty strings for the allocation fields. There is no behaviour on it. The sampling order is significant for reproducibility only through the named streams in Utils: each selector draws from its own generator, so reordering these calls does not change the numbers each stream produces (see Chapter 3).
Key attributes sampled here:
- AMPDS card: the dispatch-code category of the call (see the glossary); it depends on the hour of day (
ampds_code_selection(pt.hour)). - Care category (
CC,ECorREG): depends on the AMPDS card. - Patient outcome: depends on care category and quarter.
The first event-log row, ("arrival", "arrival_departure"), is written before any resource logic, so every patient has an arrival row even if nothing is sent.
Next, if amb_data is true the code flips a coin on whether the case is HEMS or ambulance. Otherwise hems_case = 1, which is the normal case and a fair one: the demand pool is built from jobs recorded by the HEMS desk, including jobs reviewed afterwards that arrived while no vehicle was staffed, so it is not an estimate of every emergency a helicopter might attend, and there is no non-HEMS population to split off. For HEMS cases it samples the preferred callsign group, the helicopter benefit flag and the preferred vehicle type, logging each as its own row. Then comes the branch that decides the allocation path:
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 take the allocate_resource path only half of the time (know_cc_ec_benefit stream, threshold 0.5). Everything else, including the other half of CC/EC, goes through allocate_regular_resource. The 0.5 is a hard-coded literal. The rationale is documented: call handlers sometimes know in advance that a job would benefit from CC/EC, so allocation can only target CC/EC resources for some of those jobs (commit e18e48c; commit 9b559c2 adjusted this so that helicopter benefit is not always known in advance). The value 0.5 itself has no cited source.
Both calls return [primary, reason_text, secondary]. The reason is logged as resource_preferred_outcome, and patient_journey is started either with the resources or with None, pt, None when nothing was available. How the list is built is Chapter 6.
The patient journey
patient_journey() is one long generator: a sequence of “log a row, sample a duration, yield a timeout” blocks, gated by whether a resource exists and by the sampled HEMS result.
flowchart TD
A["resource?"] -->|no| X["rows: No HEMS available,<br/>No Resource Available"]
X --> Z["depart"]
A -->|yes| B["sample hems_result"]
B --> C["time_allocation"]
C --> D["time_mobile"]
D --> E{"Stand Down?"}
E -->|yes| H["time_to_clear"]
E -->|no| F["time_to_scene, time_on_scene"]
F --> G{"result in Stand Down,<br/>Landed but no patient contact,<br/>Treated not conveyed?"}
G -->|yes| H
G -->|no| I["time_to_hospital"]
I --> H
H --> Z
Stand Down means the crew was cancelled before reaching the scene; “Landed but no patient contact” and “Treated not conveyed” (in code, "Patient Treated but not conveyed by HEMS") are results where nobody is taken to hospital, which is why the hospital leg is skipped.
The first part handles the no-resource case and, otherwise, copies the allocated vehicle’s identity onto the patient and samples the HEMS result.
air_ambulance_des/des_hems.py:479-530
try:
patient_enters_sim = self.env.now
# Note that 'add_patient_result_row' does its own check for whether it's currently within
# the warm-up period, so this does not need to be checked manually when adding result
# rows in this section
# not_in_warm_up_period = False if self.env.now < self.warm_up_duration else True
# Add variables for quick determination of whether certain parts of the process should
# be included per case
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"
)
# if hems_avail
else:
# Record selected vehicle type and callsign group in patient object
patient.hems_vehicle_type = hems_res.vehicle_type
patient.hems_registration = hems_res.registration
patient.callsign = hems_res.callsign
self.add_patient_result_row(
patient, patient.hems_vehicle_type, "selected_vehicle_type"
)
self.add_patient_result_row(
patient, patient.hems_callsign_group, "selected_callsign_group"
)
# patient.hems_result = self.utils.hems_result_by_callsign_group_and_vehicle_type_selection(patient.hems_callsign_group, patient.hems_vehicle_type)
# self.debug(f"{patient.hems_cc_or_ec} and {patient.hems_helicopter_benefit}")
# patient.hems_result = self.utils.hems_result_by_care_category_and_helicopter_benefit_selection(patient.hems_cc_or_ec, patient.hems_helicopter_benefit)
# Determine outcome
# If we know a resource has been allocated, we can determine the output from historical patterns
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"
The HEMS result is sampled after allocation, conditioned on patient outcome, quarter, vehicle type, callsign group and hour. That is why time_of_day appears in the very long Utils method name, hems_results_by_patient_outcome_and_time_of_day_and_quarter_and_vehicle_type_and_callsign_group_probs (commit 9b32e45 added the time-of-day dimension). Two booleans derived from the result then skip later stages: no_HEMS_at_scene is true only for "Stand Down", and no_HEMS_hospital is true for "Stand Down", "Landed but no patient contact" and "Patient Treated but not conveyed by HEMS" (des_hems.py:545-559).
Each stage then follows the same pattern. Here is allocation:
air_ambulance_des/des_hems.py:574-588
# Allocation time --------------
# Allocation will always take place if a resource is found
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, name) returns a sampled duration (the upper-bound clipping lives there, see Chapter 3), and activity_duration_multiplier scales it. Note the order: the duration row is written first, then the queue row, then the timeout. So a row’s timestamp is the start of the stage and the duration is carried in time_type as a number. The stages are time_allocation, time_mobile, time_to_scene, time_on_scene, time_to_hospital and time_to_clear, in that order. Handover is explicitly “Not currently available” (comment at line 687).
Finally the journey ends with a depart row, and the resource is always returned:
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 try/finally is the invariant to keep. If any sampling call raises inside the journey, the resource still goes back to the store, and a resource_use_end row is written. The depart row is only written on the success path. Commit 192fd1d (“Patient journey refactored”) is the documented change that reshaped this function; the commit history does not state why the finally was introduced, so the safety intent is inferred.
The event log
Every row goes through one function:
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)time_type is the what (a stage name, a callsign, a number of minutes) and event_type the kind (arrival_departure, queue, resource_use, resource_use_end and the patient_* / resource_* descriptors). Downstream code in Chapter 7 filters on event_type values, so these strings are an implicit schema. Every row is a snapshot of the patient’s current attributes, which is why early rows (before allocation) have empty callsign and registration and later rows have them filled.
The warm-up guard on the last two lines is the only place warm-up is enforced. It replaced an earlier warm-up flag (commit 3bf122e). Because the patient still runs through the whole journey during warm-up, the resources are used normally; only the log is suppressed. **kwargs lets callers add extra columns, but nothing in this file passes any.
At the end of run(), convert_results_to_df builds results_df indexed by P_ID, and the two write methods persist it (Chapter 2).
Extending this
Add a patient attribute. Add a default in Patient.__init__ (class_patient.py), sample it in generate_patient() with a new named stream (add the stream name in Utils.setup_seeds and the selector in Utils; see Chapter 3), and add a key in add_patient_result_row() if it should reach the CSV. Then check TrialResults (Chapter 7) for column assumptions. If the attribute needs a new input file, it is produced by DistributionFitUtils (Chapter 8).
Add a new event-log row. Call self.add_patient_result_row(patient, <time_type>, <event_type>). Choose a new event_type string deliberately: downstream filters key on it.
Add or reorder a journey stage. Edit patient_journey(). Each stage needs: a matching activity_time name in the fitted distributions, a duration row, a queue row, a yield self.env.timeout(...), and a patient.time_in_sim update. If the stage can be skipped, extend the no_HEMS_* booleans. The flowchart in Figure 2 is the logic to keep in step.
Change the arrival process. generate_calls() and calls_per_hour(). The ia_dict hand-off between the new-day block and the per-hour block is a local variable, so any replacement must still set it on the first iteration of each day.
Change how helicopter benefit is sampled. The block at lines 356-411 of generate_patient(). See the sharp edges below before touching the hems_result check.
Tests.
| File | Covers |
|---|---|
tests/test_call_generation.py |
Includes test_servicing_checked_on_days_with_no_calls; days with zero calls, first-day partial starts, servicing on zero-call days, reduced demand against historic calls |
tests/test_unittest_model.py |
Model-level unit tests |
tests/test_against_condition_changes.py |
Behaviour when conditions (rotas, demand) change |
tests/test_against_reality.py |
Comparison with observed patterns |
Run everything with pytest from the repository root. README.md documents marker-based subsets (pytest -m resources is its example) and -k matching; test_call_generation.py carries the calls marker, so pytest -m calls selects it; pytest.ini lists the registered markers.
Sharp edges
The amb_data branch assigns a comparison, not a value.
air_ambulance_des/des_hems.py:337-347
if self.amb_data:
# TODO: We'll need the logic to decide whether it is an ambulance or HEMS case
# if ambulance data is being collected too.
self.debug("Ambulance case")
pt.hems_case = (
1
if self.utils.rngs["hems_case"].uniform(0, 1) <= 0.5
else pt.hems_case == 0
)
else:
pt.hems_case = 1pt.hems_case == 0 evaluates to a boolean, and that boolean is assigned. When the coin flip fails, hems_case is False rather than 0. It happens to work because False == 1 is false in the later checks, but a reader assuming 0 will be misled. With amb_data on, a non-HEMS patient never gets a patient_journey process at all (it is only started inside if pt.hems_case == 1), so the ambulance branches in patient_journey are only reached for HEMS cases. The TODO at line 338 says the ambulance/HEMS decision logic is unfinished.
The “conveyed by HEMS means benefit” override cannot fire.
air_ambulance_des/des_hems.py:408-411
if pt.hems_result == "Patient Conveyed by HEMS":
helicopter_benefit = "y"
pt.hems_helicopter_benefit = helicopter_benefithems_result is only assigned in patient_journey, after allocation, and Patient initialises it to "". So at this point the condition is always false. The comment above it (lines 399-406) says the author commented something out and wants to revisit it; the live code is not commented out, but it is inert. Commit ea4909f (discussion in issue #223) documents the intent that benefit is always set when HEMS conveys. Do not assume it holds in the current event log.
Distribution files are read per patient. The three proportion_jobs_heli_benefit*.txt files are opened and parsed inside generate_patient() for every HEMS patient (lines 361-390). It is harmless at this scale but it is I/O in the hot path, and the paths are relative to the working directory.
Calls can be silently dropped. If the waits sum to 59 before every call allocated to an hour is placed, the remaining calls are skipped (see the call generator above), so fewer patients arrive than calls_per_hour allocated. This follows from reading the code; the tests in test_call_generation.py are the place to check call counts (issue #165 concerns discrepancies against historic metrics).
Zero-call days. calls_today and new_day are instance state; ia_dict is a local that is only assigned on a new day. The zero-call fix (commit af2ccf2) and its tests are the reference for what must keep working: no arrivals on a zero-call day, and servicing still checked.
The finally only returns resources. It does not remove the process from the SimPy event queue and does not write a depart row on failure, so a crash mid-journey yields a log with a resource_use row and a resource_use_end row but no depart.
Log file side effect. logging.basicConfig(filename="log.txt", filemode="w", ...) runs at import (line 30), truncating log.txt in the working directory each time the module is imported. Debug messages are only written when print_debug_messages is true.
Design notes
- Minutes as the unit of time. Documented: commit
1f646db(2024-10-25) changed the simulation unit to minutes. This is also whydate_time_of_calltakes elapsed minutes. - Warm-up by suppressing the log, not by a flag. Documented: commit
3bf122e(2025-04-02) records that rows are not stored during warm-up, replacing a warm-up flag. The code suggests the benefit is that no journey code needs to know about warm-up. - Three processes per call. Inferred: splitting generation, patient creation and journey means a patient can block on the resource request without stalling the call generator.
- Time-of-day in the HEMS result. Documented: commit
9b32e45(2025-05-19). - Per-purpose random streams. The streams
calls_per_hour,call_iat,know_cc_ec_benefitand the threehelicopter_benefit_from_*streams keep each draw independent; see Chapter 3.
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.
- TODO at
des_hems.py:338: logic to decide ambulance versus HEMS case is missing. - TODO at
des_hems.py:404: helicopter benefit may be slightly overestimated; see the sharp edge above. - TODO at
des_hems.py:778:convert_results_to_dfswallows aKeyError(empty results, noP_IDcolumn) withpass. - TODO at
class_patient.py:42:current_dtis initialised toNonerather than a datetime. - Open as of 2026-10-19: #192 (patient outcome is sometimes “Patient Conveyed” when the vehicle is a car), #205 (update assumptions on the percentage of REG jobs with helicopter benefit), #168 (calls-per-day visualisation) and #165 (discrepancies against historical metrics).
For anything newer, check the issue tracker.
Conclusion
Next, in Chapter 5, we look at what HEMSAvailability and HEMS actually are, and what daily_servicing_check does when generate_calls() yields on it.