flowchart LR
raw["external_data/ job-level CSV"] --> dfu["DistributionFitUtils"]
adhoc["external_data/ad_hoc.csv"] --> dfu
bounds["actual_data/upper_allowable_time_bounds.csv"] --> dfu
dfu --> dist["distribution_data/"]
dfu --> hist["historical_data/"]
dfu --> sh["actual_data/school_holidays.csv"]
dist --> utils["Utils (sampling)"]
hist --> tr["TrialResults / HistoricResults"]
dfu -. "run_sim_on_historical_params" .-> sim["parallelProcessJoblib"]
Chapter 8: Distribution Fitting
Chapter 7 read historical_data/ as if it had always been there. This chapter is about the script that writes it.
What it is
DistributionFitUtils is an offline batch pipeline. It takes one input, a cleaned job-level CSV (one row per historical HEMS job), and writes two kinds of output:
distribution_data/: fitted distributions and probability tables that the simulation samples from. They are loaded once byUtils(see Sampling Distributions and Random Streams).historical_data/: aggregated observed totals, whichTrialResultsand the historic comparison pages plot against simulated output.
It is not part of a simulation run. Nothing in DES_HEMS calls it; the app and the replications only ever read what it last wrote. You run it when you have new historical data (the concrete use case: refitting after a new year of jobs arrives), and the entry point is the five-line script run_dist_fit_utils.py.
The contract callers rely on:
- Input: a CSV with the columns the methods index into directly (
inc_date,ampds_card,vehicle_type,hems_result,time_*and so on). Nothing validates them. - Output: files at fixed relative paths. Names and column layouts are an API, because
Utilsreads them by name. - Side effects: it deletes everything in both output folders first, and a few methods also write to
actual_data/.
How it works
The entry point
The whole script is a constructor call and two method calls.
run_dist_fit_utils.py:1-10
if __name__ == "__main__":
from air_ambulance_des.distribution_fit_utils import DistributionFitUtils
test = DistributionFitUtils(
"external_data/clean_daa_import_missing_2023_2024.csv",
calculate_school_holidays=True,
)
# test = DistributionFitUtils('external_data/clean_daa_import.csv')
test.import_and_wrangle()
test.run_sim_on_historical_params()Paths are relative to the current working directory, so run it from the repo root. The input filename is hard-coded; edit the script to point at a different extract.
Importing and sequencing
import_and_wrangle() is the orchestrator. It loads the CSV, derives time columns (hour, day_of_week, month, quarter, first_day_of_month), reads the manual time bounds, wipes the output folders, and then calls one method per output file in a fixed order.
air_ambulance_des/distribution_fit_utils.py:204-232
self.min_max_values_df = pd.read_csv(
"actual_data/upper_allowable_time_bounds.csv"
)
# print(self.min_max_values_df)
# This will be needed for other datasets, but has already been computed for DAA
# self.df['ampds_card'] = self.df['ampds_code'].str[:2]
self.removeExistingResults(Utils.HISTORICAL_FOLDER)
self.removeExistingResults(Utils.DISTRIBUTION_FOLDER)
# get proportions of AMPDS card by hour of day
self.hour_by_ampds_card_probs()
# Determine 'best' distributions for time-based data
self.activity_time_distributions()
# Calculate probability patient will be female based on AMPDS card
self.sex_by_ampds_card_probs()
# Determine 'best' distributions for age ranges straitifed by AMPDS card
self.age_distributions()
# Alternative approach to IA times. Start with probabilty of call at given hour stratified by quarter
self.hourly_arrival_by_qtr_probs()
# Calculates the mean and standard deviation of the number of incidents per day stratified by quarter
self.incidents_per_day()
self.incidents_per_day_samples()The removeExistingResults calls delete every *.* file directly in those folders. If a later method crashes, you are left with a half-populated distribution_data/ and the simulation will fail to load. After that block come the probability tables (enhanced and critical care, HEMS result, callsign group, vehicle type, patient outcome), then school_holidays() if requested, then the historical_* family, then ad_hoc_unavailability(...).
Several methods are commented out under “ARCHIVED CODE” banners, and vehicle_type_probs is commented out of the sequence even though a method of that name still exists. Commit 899dbe2 (“Archive some distribution data that is no longer in use”) is probably related to these banners (inferred; the commit message talks about data, not code).
Picking a best-fit distribution
All continuous fitting goes through one helper.
air_ambulance_des/distribution_fit_utils.py:142-163
def getBestFit(self, q_times, distr=get_common_distributions(), show_summary=False):
"""
Convenience function for Fitter.
Returns model and parameters that is considered
the 'best fit'.
TODO: Determine how Fitter works this out
"""
if q_times.size > 0:
if len(distr) > 0:
f = Fitter(q_times, timeout=60, distributions=distr)
else:
f = Fitter(q_times, timeout=60)
f.fit()
if show_summary == True:
f.summary()
return f.get_best()
else:
return {}Fitter is the class from the third-party fitter package (from fitter import Fitter, get_common_distributions, line 10 of distribution_fit_utils.py), so its error measure and distribution names are that package’s. It tries each candidate, ranks them by its default error measure, and get_best() returns a one-entry dict mapping the scipy distribution name to its parameters. An empty series returns {}, which is written to disk as-is, so a stratum with no data produces an empty fit rather than an error. The TODO at line 149 records that nobody has pinned down exactly how Fitter ranks candidates.
Callers pass self.sim_tools_distr_plus, a hard-coded list of 20 names set in __init__ (lines 104-125). The comment there says it was hard-coded on 16-04-2025 “to make setup for random number generation more robust”, and the get_common_distributions() default in the signature (also from fitter) is now effectively unused. Inference: the list is restricted to distributions that the sampling code in chapter 3 knows how to construct, since a fitted name the sampler can’t build would only fail at run time.
Stratified time fits
activity_time_distributions() is the main use of getBestFit. It loops over HEMS result, time type and vehicle type, filters to the allowed range from actual_data/upper_allowable_time_bounds.csv, and fits.
air_ambulance_des/distribution_fit_utils.py:376-416
# This line might not be required if data quality is determined when importing the data
max_time = self.min_max_values_df[
self.min_max_values_df["time"] == ttf
].max_value_mins.iloc[0]
min_time = self.min_max_values_df[
self.min_max_values_df["time"] == ttf
].min_value_mins.iloc[0]
# print(f"Max time is {max_time} and Min time is {min_time}")
if ttf == "time_on_scene":
# There is virtually no data for HEMS_result other than patient conveyed
# which is causing issues with fitting. For time on scene, will
# use a simplified fitting ignoring hems_result as a category
fit_times = self.df[
(self.df.vehicle_type == vt)
& (self.df[ttf] >= min_time)
& (self.df[ttf] <= max_time)
][ttf]
else:
fit_times = self.df[
(self.df.vehicle_type == vt)
& (self.df[ttf] >= min_time)
& (self.df[ttf] <= max_time)
& (self.df.hems_result == row["hems_result"])
][ttf]
# print(fit_times[:10])
best_fit = self.getBestFit(
fit_times, distr=self.sim_tools_distr_plus
)
# print(best_fit)
return_dict = {
"vehicle_type": vt,
"time_type": ttf,
"best_fit": best_fit,
"hems_result": row["hems_result"],
"n": len(fit_times),
}
# print(return_dict)
final_distr.append(return_dict)The time_on_scene branch drops the hems_result filter, because there is almost no data for results other than “conveyed” (the comment at lines 387-389 says so). Every other time is stratified by result and vehicle type. Each record carries n, the sample size, which is the only signal downstream of how thin a stratum was. The list is dumped as JSON text into activity_time_distributions.txt, and Utils reads it back with ast.literal_eval (air_ambulance_des/utils.py:142-154), so the file must remain a Python-literal-compatible structure.
Call volumes and arrival hours
Daily call counts are fitted per season and per quarter in incidents_per_day(), with year weights applied by repeating rows.
air_ambulance_des/distribution_fit_utils.py:517-521
# Weight settings - simple implementation rather than biased mean thing
year_weights = {
2023: 1.0,
2024: 4.0, # 10% more weight to 2024
}incidents_per_day_samples() builds a second, empirical version of the same data (a list of daily counts per season and Q1-Q4), and uses different default weights.
air_ambulance_des/distribution_fit_utils.py:612-614
# Year weight map
if weight_map is None:
weight_map = {2023: 1.0, 2024: 1.1}The two methods therefore weight 2024 differently (4.0 against 1.1), and both hard-code the years 2023 and 2024. See the sharp edges below.
Hourly arrivals are the simplest kind of output: a group-by, a proportion within each quarter, and a CSV.
air_ambulance_des/distribution_fit_utils.py:863-873
ia_df = self.df[["quarter", "hour"]].dropna()
hourly_counts = (
ia_df.groupby(["hour", "quarter"]).size().reset_index(name="count")
)
total_counts = hourly_counts.groupby(["quarter"])["count"].transform("sum")
hourly_counts["proportion"] = round(hourly_counts["count"] / total_counts, 4)
hourly_counts.sort_values(by=["quarter", "hour"]).to_csv(
"distribution_data/hourly_arrival_by_qtr_probs.csv", mode="w+"
)Most of the *_probs methods follow this shape: count, divide by a stratum total, round, write. The rounding (3 or 4 decimal places) is applied to the stored proportions, so rows may not sum to exactly 1.
The historical baseline run
The last method is different in kind: it runs the simulation.
air_ambulance_des/distribution_fit_utils.py:2712-2730
print("Generating simulation results...")
removeExistingResults()
total_runs = 30
sim_years = 2
sim_duration = 60 * 24 * 7 * 52 * sim_years
parallelProcessJoblib(
total_runs=total_runs,
sim_duration=sim_duration,
warm_up_time=0,
sim_start_date=datetime.strptime(
"2023-01-01 05:00:00", "%Y-%m-%d %H:%M:%S"
),
amb_data=False,
print_debug_messages=True,
)
collateRunResults()Before this block (lines 2692-2710) it overwrites the live actual_data/ rota, callsign lookup, service history and service schedule files with the tests/rotas_historic/HISTORIC_* versions. It then runs 30 replications of two simulated years through parallelProcessJoblib with no warm-up and amb_data=False, and collates the results (see Simulation Runs and Replications). The remainder summarises how often a sub-optimal care category or vehicle type was sent, as mean/min/max across runs, into historical_data/calculated/SIM_hist_params_suboptimal_*_summary.csv. Those are the files the historic comparison in chapter 7 reads for the “what historic rotas would have done” baseline. The commit “Summarised data used for calculating suboptimal resource allocation” (2540486) is the documented origin.
Extending this
There are no automated tests for this module. tests/ has no file that exercises DistributionFitUtils. The nearest checks are indirect: tests/test_backtest.py and tests/test_against_reality.py run the simulation on whatever distribution files are present, so a bad refit shows up there rather than here. See tests/README.md for the pytest commands. Treat any change here as untested and compare the regenerated files by hand (a git diff on distribution_data/ is the practical check).
Typical changes:
- Add a new probability table. Write a method that follows the count/divide/round/write shape of
hourly_arrival_by_qtr_probs, call it fromimport_and_wranglebefore thehistorical_*block, then add a read for the new file inUtils.__init__(air_ambulance_des/utils.py, alongside lines 80-175) and use it in the sampling code. The pipeline wipes the folder, so a table that nothing writes simply vanishes on the next refit. - Add a new activity time or HEMS result. Add an entry to
self.times_to_fitin__init__(lines 55-102). Make sureupper_allowable_time_bounds.csvhas a row for that time (the lookup uses.iloc[0]and raisesIndexErrorif it is missing), and that the simulation code indes_hems.pyconsumes the new key. - Allow another distribution. Add its name to
sim_tools_distr_plus(lines 104-125), and check that the sampling side can construct it. - Add an aggregated historical output. Add a
historical_*method, call it in the block at lines 285-296, and read the file fromTrialResultsorHistoricResults. Document the columns inhistorical_data/README.md. - Change the data source. Edit
run_dist_fit_utils.py, and the hard-codedexternal_data/ad_hoc.csvand the ad hoc period inimport_and_wrangle.
Conventions: output paths are plain strings relative to the repo root (not Utils.DISTRIBUTION_FOLDER, though the wipe step uses the Utils constants), CSV writes use mode="w+", and fitted distributions are written as JSON text files with a .txt extension.
To run it, from the repo root: python run_dist_fit_utils.py. The README says the required input format is not currently documented, and that the committed distribution_data/ lets the rest of the app run without raw data.
Sharp edges
- Destructive and non-atomic. Both output folders are emptied before anything is written (lines 212-213). A failure mid-run leaves partial output. Commit the folders before refitting so you can restore them.
- Overwrites live inputs.
run_sim_on_historical_paramsreplaces four files inactual_data/with the historic versions. Any rota edited through the app is lost.school_holidays()also rewritesactual_data/school_holidays.csv. - Hard-coded years and periods.
incidents_per_dayandincidents_per_day_samplesonly weight 2023 and 2024; other years get weight 1.0 through.fillna(1.0). The ad hoc period is fixed inimport_and_wrangle. - Inconsistent weights.
incidents_per_dayuses 4.0 for 2024 with a comment that says “10% more weight”. The sampling version uses 1.1. The comment and the number disagree and the two methods differ. Which is intended is not documented, so confirm with the maintainers before changing either. Inincidents_per_day2024 rows are repeated 40 times against 10 for 2023 (weight x 10, rounded). - Silent data problems. Missing file handling in
import_and_wrangleprints a message and then carries on to index an empty DataFrame, which raises aKeyErroroninc_date. The TODO at line 172 notes that there is no column checking at all. Empty strata give{}fits, not errors. - Relative paths. Reads and writes assume the repo root as the working directory. The module imports
Utilsanddes_parallel_processas top-level modules (from utils import Utils). This works only because importing theair_ambulance_despackage runssys.path.append(...)inclass_simulation_trial_results.py:98; importing the module standalone from another directory would fail. - Fitting cost.
Fitteris giventimeout=60per call andactivity_time_distributionsmakes one call for every combination of result, time type and vehicle type, so a full run is slow. Parallelising the fits is the obvious improvement, but nothing has been done on it. - Unused helpers.
upper_allowable_time_bounds()only prints a quartile table and writes nothing; the bounds are read from the CSV instead. The comment at lines 199-203 explains that this is so values can be edited in the front end (documented in the code comment).
Design notes
- Offline and file-based. Documented (
README.md): the committeddistribution_data/lets the app run without raw job-level data. Inferred: this is why the pipeline is kept out ofDES_HEMS, which simply reads plain CSV and JSON-text files. - Probability tables are stratified by what the model samples (inferred). Each table’s grouping keys match the lookups made during a patient journey, for example quarter and hour for arrival, AMPDS card for care category.
- Callsign group probabilities are deliberately conditioned (documented). Commits
7fa1a27and5cdd3b8explain removal of stratification, overlapping jobs and ad hoc downtime. Helicopter benefit is calculated per care category (commit0915eaa). - Time bounds moved to a manual CSV. Documented (code comment, lines 199-203): this replaces an upper-quartile rule so values can be edited in the front end. Documented (commit
387af74): whole rows are no longer dropped when a single time is missing. Nothing states why a CSV rather than a setting was chosen. - A baseline run lives here (inferred). It needs the freshly fitted files and the historic rotas, and it is only meaningful just after a refit, which is presumably why it sits beside the fitting rather than in the app.
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.
The TODO markers in the module:
distribution_fit_utils.py:20: “Important fix for rerunning with new input data” (a commented-out visualisation import).:149: determine howFitterworks out the best fit.:172: add logic to check the imported CSV.:313: whether the AMPDS card by hour table should also be stratified by quarter.:985(repeated at:1165): remove jobs where a helicopter is known to be off for servicing.:1254: the probability calculation functions could probably be refactored into one.:2737: save the parameters used for the historical run.
Related open issues, as of 2026-10-19: #187 (car/helicopter split within callsign groups), #205 (assumed share of REG jobs with a helicopter benefit) and #165 (discrepancies between simulated and historical metrics).
Check the issue tracker for the current state.
Conclusion
Nothing follows this chapter. If you changed an output file here, check the consumers in Chapter 3 and Chapter 7; the glossary covers the shared terms.