flowchart LR
A["app/model.py<br/>or run_parallel_process.py"] --> B[parallelProcessJoblib]
B -->|"SeedSequence(master_seed).spawn(n)"| C[runSim x n]
C --> D[DES_HEMS.run]
D --> E["data/output_run_N.csv"]
C -->|"run == 0"| F["data/run_params_used.csv"]
E --> G[collateRunResults]
G --> H["data/run_results.csv"]
D --> I["data/all_results.csv"]
Chapter 2: Simulation Runs and Replications
Chapter 1 ended with the Run button handing session-state values to the run functions. This chapter follows that call into the package.
What it is
air_ambulance_des/des_parallel_process.py is the package’s run API. runSim builds one DES_HEMS model for a run number and seed, calls run() and returns that run’s results DataFrame. parallelProcessJoblib turns a single master seed into one per-run seed per replication, then fans runSim out over CPU cores with joblib. collateRunResults merges the per-run CSVs into data/run_results.csv, which is the file the results classes read (see Chapter 7).
The contract callers rely on:
- Inputs: durations are in simulated minutes (the model’s unit of time),
warm_up_timeis in the same unit, and the other parameters come from the scenario the app wrote toactual_data/. - Outputs are files, not return values. Every run writes
data/output_run_N.csv(N starts at 1); the first run also writesdata/run_params_used.csvviawrite_run_params. The return value ofparallelProcessJoblibis a list of DataFrames that no in-repo caller uses. - Determinism: the same
master_seedandtotal_runsgive the same per-run seeds, whatevern_coresis.
Warm-up period: simulated minutes at the start of a run whose events are run but not recorded. It lets the model reach a realistic state (resources mid-job, servicing under way) before results begin.
Replication: one independently seeded run of the whole model. Results are only meaningful as a set of replications.
The use case for this chapter is the one every scenario comparison starts with: twelve replications of a two-year scenario from the app’s Run button or from run_parallel_process.py, producing one combined event log.
The call generator and sampling the run relies on are covered in Chapter 4 and Chapter 3.
How it works
Deriving one seed per replication
air_ambulance_des/des_parallel_process.py:294-319
# Generate a number of uncorrelated seeds that will always be the same given the same
# master seed (which is determined as a parameter)
# We start with a SeedSequence from the master seed, and then generate a number of
# child SeedSequences equal to the total number of runs
seed_sequence = SeedSequence(master_seed).spawn(total_runs)
# We then turn these seeds into integer random numbers, and we will pass a different seed
# into each run of the simulation.
seeds = [i.generate_state(1)[0] for i in seed_sequence]
# Run the simulation in parallel, using all available cores
return Parallel(n_jobs=n_cores)(
delayed(runSim)(
run=run,
total_runs=total_runs,
sim_duration=sim_duration,
warm_up_time=warm_up_time,
sim_start_date=sim_start_date,
amb_data=amb_data,
random_seed=seeds[run],
save_params_csv=save_params_csv,
demand_increase_percent=demand_increase_percent,
activity_duration_multiplier=activity_duration_multiplier,
print_debug_messages=print_debug_messages,
)
for run in range(total_runs)
)SeedSequence(master_seed).spawn(total_runs) yields total_runs child sequences. Each is collapsed to a single integer with generate_state(1)[0] and passed to runSim as random_seed. The seed for replication i is therefore a function of (master_seed, i) only. Nothing depends on which worker picks up the job, so results do not change with n_cores. Because SeedSequence.spawn keys children by index (numpy behaviour, not repo code), asking for more runs should not change the seeds of the earlier ones. tests/test_reproducibility.py checks same-seed and different-seed behaviour end to end.
Parallel(n_jobs=n_cores) with the default n_cores=-1 uses every core. The generator expression passes every setting explicitly, so runSim never reads session state or globals for its configuration.
One replication
air_ambulance_des/des_parallel_process.py:167-197
start = time.process_time()
print(f"{Utils.current_time()}: Run {run + 1} of {total_runs}")
logging.debug(f"{Utils.current_time()}: Run {run + 1} of {total_runs}")
# print(f"Sim start date is {sim_start_date}")
daa_model = DES_HEMS(
run_number=run,
sim_duration=sim_duration,
warm_up_duration=warm_up_time,
sim_start_date=sim_start_date,
amb_data=amb_data,
random_seed=random_seed,
demand_increase_percent=demand_increase_percent,
activity_duration_multiplier=activity_duration_multiplier,
print_debug_messages=print_debug_messages,
)
daa_model.run()
print(
f"{Utils.current_time()}: Run {run + 1} took {round((time.process_time() - start) / 60, 1)} minutes to run"
)
logging.debug(
f"{Utils.current_time()}: Run {run + 1} took {round((time.process_time() - start) / 60, 1)} minutes to run"
)
# SR NOTE: This could cause issues if we decide to use 1 as the starting number of runs
if (run == 0) and (save_params_csv):
write_run_params(daa_model)
return daa_model.results_dfrunSim is deliberately thin. It constructs DES_HEMS, calls run(), and returns daa_model.results_df. The timing uses time.process_time(), which is CPU time of the worker process, not wall-clock time, so the “took N minutes” message is not a stopwatch reading.
Parameter recording is gated on run == 0 and save_params_csv. The SR NOTE code comment above it (a maintainer’s note-to-self convention in this repo) is a warning to keep in mind: the check assumes runs are numbered from zero.
What DES_HEMS does with the seed and the run number
air_ambulance_des/des_hems.py:56-71
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()Two things matter here. First, self.run_number = run_number + 1: the model’s run number is one-based, so the file for the first replication is output_run_1.csv while runSim still sees run == 0. Second, the integer seed is wrapped in a second SeedSequence, and a spawned child of that goes to Utils, whose setup_seeds() makes the named random streams. How those streams are derived is the subject of Chapter 3. HEMSAvailability is also handed the parent SeedSequence as master_seed, but it only stores it and does not use it. Its random draws (ad hoc unavailability, know_heli_benefit) come from the shared Utils instance and so from the streams described in Chapter 3.
The warm-up period and the end of the run
air_ambulance_des/des_hems.py:764-772
if self.env.now >= self.warm_up_duration:
self.store_patient_results(results)
def store_patient_results(self, results: dict) -> None:
"""
Adds a row of data to the Class' `result_df` dataframe
"""
self.results_list.append(results)The warm-up is not a flag and not a separate phase. Events during the warm-up are simulated but simply not appended to results_list, because add_patient_result_row checks self.env.now >= self.warm_up_duration (Chapter 4 covers the rows themselves). The commit that removed the old warm_up_period boolean (“rows are simply not added during the warm-up period”, 3bf122e, 2025-04-02) documents this.
air_ambulance_des/des_hems.py:812-832
def run(self) -> None:
"""
Function to start the simulation.
"""
self.debug(
f"HEMS class initialised with the following: run {self.run_number}, duration {self.sim_duration}, warm-up {self.warm_up_duration}, start date {self.sim_start_date}, demand increase multiplier {self.demand_increase_percent}, activity duration multiplier {self.activity_duration_multiplier}"
)
# Start entity generators
self.env.process(self.generate_calls())
# Run simulation
self.env.run(until=(self.sim_duration + self.warm_up_duration))
# Convert results to a dataframe
self.convert_results_to_df(results=self.results_list)
# Write run results to file
self.write_all_results()
self.write_run_results()run() starts the generate_calls process, runs the SimPy environment until sim_duration + warm_up_duration, converts the row list to a DataFrame and then writes two files. The recorded window is therefore exactly sim_duration long and starts at warm_up_duration.
Writing and collating
air_ambulance_des/des_hems.py:795-810
def write_run_results(self) -> None:
"""
Writes the content of `result_dfs` to csv files that contains only the results from the
single run
Note that this cannot be done in a similar manner to write_all_results due to the impacts of
the parallisation approach that is taken with joblib - depending on process timings it can lead to
not all
"""
self.results_df.to_csv(
f"{Utils.RESULTS_FOLDER}/output_run_{self.run_number}.csv",
header="column_names",
index=True,
encoding="utf-8-sig",
)The docstring is the documented reason for per-run files: appending to one shared file does not work reliably under joblib, because it depends on process timing. write_all_results (just above, at lines 782-793) still appends to data/all_results.csv from every worker; nothing in the collation path reads that file.
air_ambulance_des/des_parallel_process.py:200-211
def collateRunResults() -> None:
"""
Collates results from a series of runs into a single csv
"""
matching_files = glob.glob(os.path.join(Utils.RESULTS_FOLDER, "output_run_*.csv"))
combined_df = pd.concat([pd.read_csv(f) for f in matching_files], ignore_index=True)
combined_df.to_csv(Utils.RUN_RESULTS_CSV, index=True, encoding="utf-8-sig")
for file in matching_files:
os.remove(file)Collation globs output_run_*.csv in Utils.RESULTS_FOLDER, concatenates them with ignore_index=True, writes run_results.csv (index column included), and deletes the per-run files. The run identity survives in the data because each row already carries a run_number column from the model, not because of anything collation does.
air_ambulance_des/des_parallel_process.py:214-230
def removeExistingResults(remove_run_results_csv=False) -> None:
"""
Removes results from previous simulation runs
"""
matching_files = glob.glob(os.path.join(Utils.RESULTS_FOLDER, "output_run_*.csv"))
for file in matching_files:
os.remove(file)
all_results_file_path = os.path.join(Utils.RESULTS_FOLDER, "all_results.csv")
if os.path.isfile(all_results_file_path):
os.unlink(all_results_file_path)
if remove_run_results_csv:
run_results_file_path = os.path.join(Utils.RESULTS_FOLDER, "run_results.csv")
if os.path.isfile(run_results_file_path):
os.unlink(run_results_file_path)removeExistingResults is the counterpart you must call before a fresh batch, because collation takes whatever matches the glob. It always removes output_run_*.csv and all_results.csv, and removes run_results.csv only on request.
The three callers
parallelProcessJoblib is called from the app, from the CLI script and from tests. The app also has a second path, which matters for seeds.
app/model.py:156-178
for run in range(st.session_state.number_of_runs_input):
run_results = runSim(
run=run,
total_runs=st.session_state.number_of_runs_input,
sim_duration=float(st.session_state.sim_duration_input * 24 * 60),
warm_up_time=float(st.session_state.warm_up_duration * 60),
sim_start_date=datetime.combine(
datetime.strptime(
st.session_state.sim_start_date_input, "%Y-%m-%d"
).date(),
datetime.strptime(
st.session_state.sim_start_time_input, "%H:%M"
).time(),
),
amb_data=st.session_state.amb_data,
demand_increase_percent=float(st.session_state.overall_demand_mult)
/ 100.0,
activity_duration_multiplier=float(
st.session_state.activity_duration_multiplier
),
print_debug_messages=debug_messages,
random_seed=st.session_state.master_seed + run,
)When platform.processor() == "", which the code comments describe as a guess that the app is on Streamlit Community Cloud, the app skips joblib and loops over runSim, using master_seed + run as the seed (see “Two seeding schemes” under Sharp edges). It then concatenates the returned frames and writes Utils.RUN_RESULTS_CSV directly (lines 187-190), so it does not use collateRunResults on this path. Commit f77f7b8 (“Changed how run results are saved when executing sequentially rather than in parallel”) is the documented change to that behaviour.
run_parallel_process.py is the command-line equivalent:
run_parallel_process.py:7-19
if __name__ == "__main__":
removeExistingResults()
# parallelProcessJoblib(1, (1*365*24*60), (0*60), datetime.strptime("2023-01-01 05:00:00", "%Y-%m-%d %H:%M:%S"), False, False, 1.0, 1.0, True)
parallelProcessJoblib(
12,
(2 * 365 * 24 * 60),
(0 * 60),
datetime.strptime("2023-01-01 05:00:00", "%Y-%m-%d %H:%M:%S"),
False,
False,
1.0,
1.0,
)It calls removeExistingResults(), runs 12 replications of two years with no warm-up, amb_data=False, save_params_csv=False, and leaves master_seed at its default of 42. It does not call collateRunResults(), so it leaves output_run_N.csv files in data/ and writes neither run_results.csv nor run_params_used.csv.
Extending this
Add a run-level parameter (for example another multiplier). Touch these in order:
DES_HEMS.__init__(des_hems.py:44-55) to accept and store it.runSimandparallelProcessJoblibsignatures, and thedelayed(runSim)(...)call, which lists every argument explicitly.write_run_params, if the setting should be recorded inrun_params_used.csv. The DataFrame there is a hand-built dict, so a new key is not recorded automatically.- The app’s two call sites in
app/model.py(sequential and parallel) and, if wanted,DEFAULT_INPUTSand the setup page (Chapter 1). run_parallel_process.py, which passes arguments positionally. Inserting a parameter before existing positional ones shifts the meaning of the later arguments.
Change the parallel backend or worker count. Everything is in the Parallel(n_jobs=n_cores)(...) expression. Keep the seeds computed before dispatch, in the parent, so reproducibility stays independent of scheduling.
Change what is collated. Edit collateRunResults and keep Utils.RUN_RESULTS_CSV and the output_run_ filename pattern in step with DES_HEMS.write_run_results. They are linked only by a string pattern.
Tests. From the repo root (per tests/README.md):
Example usage (see tests/README.md)
pytest tests/test_reproducibility.py # same seed, same results; different seed, different results
pytest tests/test_unittest_model.py # includes the warm-up period behaviour
pytest -m backtest # compares a fixed scenario with the stored baselinetests/conftest.py builds shared simulation fixtures using exactly the removeExistingResults(), parallelProcessJoblib(...), collateRunResults() sequence. test_backtest.py restores the data folder afterwards, but the README warns that other tests delete and rewrite data/run_results.csv and data/all_results.csv, so don’t run them while you care about a results file in data/. If a seed change is intentional, the README documents pytest -m backtest --update-backtest-baseline.
Sharp edges
- Stale files poison collation.
collateRunResultsmerges whateveroutput_run_*.csvexists. SkippingremoveExistingResults()after an aborted batch mixes old and new replications. If nothing matches,pd.concaton an empty list raisesValueError. - Glob order is not sorted, so row order in
run_results.csvis not guaranteed to follow run number. Anything that depends on order should sort. - Concurrent appends to
all_results.csv. Every worker callswrite_all_results, which checksos.path.isfileand then appends. The check and write are not atomic, so in principle the header could be written twice or rows interleaved (inferred from the code, not observed). This is why the per-run files exist; treatall_results.csvas unreliable in parallel runs. - Parameters and results are not linked.
run_params_used.csvis written once, by run 0, from that model instance only. TheSR NOTEinwrite_run_paramsrecords that no identifier ties it to the results and that a failed write could leave the two out of step. Note also that itssim_end_dateissim_start_date + sim_duration, which does not include the warm-up, whereas the model actually runs untilsim_duration + warm_up_duration. Its summer and winter start dates are hardcoded strings (April and October), with a comment sayingclass_hemsmust be updated in step. - Two seeding schemes. Parallel: spawned per-run seeds. Sequential (hosted app):
master_seed + run. Don’t compare outputs across the two. - The one-based file number versus the zero-based run argument (see above). Someone starting
runfrom 1 would silently loserun_params_used.csv. - Unit conversions live in the caller. The app multiplies days by 24 x 60 and warm-up hours by 60 and passes floats.
runSim’s docstring says “minutes or other time unit”, but the model assumes minutes throughout. - A hung run blocks the pool. Issue #233 (open as of 2026-10-19) reports the model hanging indefinitely if a servicing schedule cannot be resolved. Joblib has no timeout here, so one such replication stalls the batch.
- Empty runs.
convert_results_to_dfswallows aKeyErroronset_index("P_ID")(theTODOatdes_hems.py:778). A run with no recorded rows therefore yields an empty frame without an error.
Design notes
- Seed sequences instead of consecutive integers. Documented: commit
9a05b28(2025-04-17) “Adjust random seeds to use seed sequences more robustly”, following2af661f(2025-04-16) which introduced random number streams for activity times. The commented-outUtils.get_distribution_seedscall above the spawn code shows an earlier attempt at a different scheme. The later reason for keepingmaster_seed + runin the hosted path is not documented; presumably it predates the change. - Per-run files plus collation. Documented in the
write_run_resultsdocstring: shared-file appends are unreliable under joblib. - Warm-up by omission. Documented:
3bf122e. - Time in minutes. Documented:
1f646db(2024-10-25) adjusted the simulation unit of time to minutes. - Everything passed explicitly into
delayed(runSim). Inferred: it keeps workers free of shared state, which suits joblib’s process-based workers. - Wrapping the integer seed in a new
SeedSequenceinsideDES_HEMS. Inferred: it letsrunSimstay a simple integer interface while the model still gets independent child streams.
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.
des_hems.py:778:TODO - Improve error handling here(the swallowedKeyError).write_run_paramscarries theSR NOTEabout linking parameter and results files with an identifier.- Open as of 2026-10-19: #183 (quantify the variation due to random seeds), #233 (hang on unresolvable servicing schedule), #165 (discrepancies against historical metrics), #245 (faster alternatives to pandas for processing run output).
Check the project’s issue tracker for the current state. For command-line usage from a user’s perspective, see the README.
Conclusion
Next, Chapter 3 opens up the Utils object each run builds from its seed and shows how the named random streams are made.