flowchart LR
X["Excel template or scenarios/*.xlsx"] --> S["setup.py"]
M["Manual widgets"] --> S
S -->|scalars| SS["st.session_state"]
S -->|sheets| CSV["actual_data/*.csv"]
D["actual_data/*_DEFAULT.csv"] -->|reset_to_defaults| CSV
SS --> R["model.py"]
R -->|arguments| RUN["runSim / parallelProcessJoblib"]
CSV -->|read at model build| RUN
Chapter 1: Streamlit App and Scenario Parameters
This is the front door of the project. The model itself starts in Chapter 2. If you only want to run the app, the readme is the better starting point.
What it is
The app/ folder is a Streamlit multipage app. It owns two kinds of state, and keeping them apart is the key to the whole chapter.
- Scalar parameters (number of runs, duration, start date, seed and so on) live in
st.session_state, seeded fromDEFAULT_INPUTSinapp/_state_control.py. They are per browser session. - Tabular parameters (rota, callsign lookup, servicing rules, school holidays, time bounds) live as CSVs in the
actual_data/folder. They are files on disk, so they are shared by everything in the process, and they are what the model reads when it is built.
The contract: the setup page (app/setup.py) turns an uploaded Excel template, a predefined scenario from scenarios/, or manual edits into those two kinds of state. The run page (app/model.py) reads the session state, converts units, and calls runSim in a loop or parallelProcessJoblib. The model never sees st.session_state; it only sees function arguments and whatever is in actual_data/ at that moment.
actual_data folder: the CSVs the model reads for rotas, servicing and time bounds. Each has a _DEFAULT twin that is never written to by the app.
The concrete use case running through this chapter: a user uploads a spreadsheet describing a 24-hour helicopter rota, with a 30-day duration and seed 7, and presses run. Which lines make that happen?
Figure 1 shows the data movement. The rota and servicing files feed HEMS resources, and the run is described in Simulation Runs and Replications.
How it works
Start-up: registering pages and priming state
app.py runs on every rerun of any page. It primes state, performs a one-off reset, and then registers the pages.
app/app.py:6-18
# Ensure state parameters (necessary for passing parameters between separate
# pages) are defined
setup_state()
if "visited_setup_page" not in st.session_state:
st.session_state["visited_setup_page"] = False
if "first_load" not in st.session_state:
reset_to_defaults(reset_session_state=False, reset_csvs=True, notify=False)
st.session_state["first_load"] = False
if platform.processor() == "":
get_quarto("daa_des") # This name must match the repository name on GitHubThe highlighted lines are the once-per-session reset. On first load only the CSVs are reset (reset_session_state=False), because setup_state() has just populated the session state anyway. first_load is itself a session key, so a browser refresh gives a new session and triggers the reset again. The platform.processor() == "" test is the app’s guess at “running on Streamlit Community Cloud”; it recurs below.
Session-state defaults
DEFAULT_INPUTS is a flat dict of every scalar the app tracks. Its tail adjusts two values depending on where the app is running.
app/_state_control.py:38-50
# Adjust some parameters depending on whether it is running
# locally on a users computer (in which case parallel processing
# can be invoked) or on the Streamlit community cloud platform
# This check is a way to guess whether it's running on
# Streamlit community cloud
if platform.processor() == "":
ADDITIONAL_INPUTS = {"sim_duration_input": 365 * 2, "number_of_runs_input": 5}
else:
ADDITIONAL_INPUTS = {"sim_duration_input": 365 * 2, "number_of_runs_input": 12}
DEFAULT_INPUTS.update(ADDITIONAL_INPUTS)Whatever the heuristic decides, sim_duration_input ends up as 730 days. The dict’s own 365 is always overridden. Only the run count differs: 5 on the guessed cloud host, 12 elsewhere. If you change a default, change it in DEFAULT_INPUTS itself unless it genuinely is host dependent.
app/_state_control.py:61-71
def setup_state():
if "rota_initialised" not in st.session_state:
# Set the rota back to defaults, overwriting any changes made
# the last time the app was run
base_rota = pd.read_csv("actual_data/HEMS_ROTA_DEFAULT.csv")
base_rota.to_csv("actual_data/HEMS_ROTA.csv", index=False)
st.session_state.rota_initialised = True
for session_state_key, session_state_default_value in DEFAULT_INPUTS.items():
if session_state_key not in st.session_state:
st.session_state[session_state_key] = session_state_default_valuesetup_state() is idempotent, which is why every page can call it. It only fills keys that are missing, so it never clobbers a value a widget has already set. The first time setup_state() runs in a session it copies HEMS_ROTA_DEFAULT.csv over HEMS_ROTA.csv and sets rota_initialised = True before the defaults loop. So the False default for that key is never used; it exists only so the key is declared. If you move this guard, make sure the rota is reset exactly once per session. The comment above DEFAULT_INPUTS credits a Streamlit forum recipe for initialising widget values that survive page changes (app/_state_control.py:5-7).
Resetting
app/_state_control.py:74-91
def reset_to_defaults(reset_session_state=True, reset_csvs=True, notify=True):
if reset_session_state:
for session_state_key, session_state_default_value in DEFAULT_INPUTS.items():
st.session_state[session_state_key] = session_state_default_value
# Overwrite csvs in data folder with their DEFAULT counterparts
# TODO: This could be refactored to cycle through each csv in this folder automatically.
# All files in this folder should have a DEFAULT counterpart.
if reset_csvs:
callsign_registration_lookup = pd.read_csv(
"actual_data/callsign_registration_lookup_DEFAULT.csv"
)
callsign_registration_lookup.to_csv(
"actual_data/callsign_registration_lookup.csv", index=False
)
base_rota = pd.read_csv("actual_data/HEMS_ROTA_DEFAULT.csv")
base_rota.to_csv("actual_data/HEMS_ROTA.csv", index=False)reset_to_defaults() has two independent halves. The first loop rewrites session state from DEFAULT_INPUTS. The second, gated by reset_csvs, copies each *_DEFAULT.csv over its working twin. Lines 93 to 121 repeat the same read-then-write pattern for rota_start_end_months, service_schedules_by_model, school_holidays, service_history, service_schedules_by_model (a second time) and upper_allowable_time_bounds. The setup page’s reset button wraps it so it can also bump the widget keys:
app/setup.py:64-67
def reset_all_to_defaults():
reset_to_defaults()
st.session_state.uploader_key += 1
st.session_state.radio_key += 1Incrementing uploader_key and radio_key changes the widget keys, which is the standard trick for forcing Streamlit to forget an uploaded file or a radio selection.
Importing a template or scenario
The setup page has three modes, selected by a radio whose labels are the constants SETUP_EXCEL, SETUP_SCENARIO and SETUP_MANUAL. The first two both reduce to a single variable, uploaded_file, which is either an upload object or a path string. That is why the same reading code serves both.
app/setup.py:121-136
# MARK: Select Scenario
elif setup_type == SETUP_SCENARIO:
scenario_files = {
f.name: str(f.relative_to("scenarios"))
for f in Path("scenarios").rglob("*.xlsx")
}
selected_scenario = st.radio(
"Select a scenario",
options=["None"] + list(scenario_files.keys()),
format_func=lambda x: x.replace(".xlsx", ""),
key=f"radio_{st.session_state.radio_key}",
)
if selected_scenario != "None":
uploaded_file = f"scenarios/{scenario_files[selected_scenario]}"Path("scenarios") is relative to the working directory, so the app must be launched from the repository root. Every .xlsx in scenarios/ becomes a choice, which is why a stray Excel lock file would show up too (the test README lists this as a planned check, not an implemented one).
The next block is the heart of the import: read sheets, validate, then overwrite.
app/setup.py:180-214
school_holidays = pd.read_excel(
uploaded_file, sheet_name="school_holidays", dtype="str"
)
# Stop before any csvs are overwritten if the school holiday dates can't be used
try:
school_holidays = parse_school_holidays(school_holidays)
except ValueError as e:
st.error(f"There is a problem with the school holidays in this file. {e}")
st.stop()
for column in ["start_date", "end_date"]:
school_holidays[column] = school_holidays[column].dt.strftime("%Y-%m-%d")
additional_parameters = pd.read_excel(
uploaded_file, sheet_name="additional_parameters"
)
# For each sheet from the template, overwrite the related csvs in the actual_data folder
# TODO: Consider whether the school holidays file needs to be added in to this
input_data_files = {
# This first file has a slightly different structure to allow for
"callsign_registration_lookup": callsign_registration_lookup.drop(
columns=["vehicle_type", "callsign_group"]
).copy(),
"HEMS_ROTA": hems_rota,
"service_schedules_by_model": service_schedules_by_model,
"rota_start_end_months": rota_start_end_months,
"service_history": service_history,
"upper_allowable_time_bounds": upper_allowable_time_bounds,
"school_holidays": school_holidays,
}
for name, df in input_data_files.items():
df.to_csv(f"actual_data/{name}.csv", index=False)Three things to notice:
- Validation happens before any write.
parse_school_holidays(inair_ambulance_des/school_holidays.py) raisesValueErroron a holiday that ends before it starts, andst.stop()halts the script run before the loop at the bottom touches a CSV.tests/test_scenario_importing.pychecks that a bad file leavesactual_dataunchanged. input_data_filesis the sheet-to-file map. Keys are CSV stems inactual_data/. Thecallsign_registration_lookupentry dropsvehicle_typeandcallsign_groupcolumns on the way out, so the CSV does not have the same columns as the sheet. The sheet shows them to the user, but they are not stored.- Validation covers only school holidays. A malformed rota sheet is written to disk as is, and will fail later inside the model build.
The remaining scalars come from the additional_parameters sheet, one lookup per session key:
app/setup.py:286-322
st.session_state.number_of_runs_input = additional_parameters[
additional_parameters["parameter"] == "number_of_runs"
]["value"].values[0]
st.session_state.sim_duration_input = additional_parameters[
additional_parameters["parameter"] == "simulation_duration_days"
]["value"].values[0]
st.session_state.warm_up_duration = additional_parameters[
additional_parameters["parameter"] == "simulation_warm_up_duration_hours"
]["value"].values[0]
st.session_state.sim_start_date_input = (
additional_parameters[
additional_parameters["parameter"] == "simulation_start_date"
]["value"]
.values[0]
.strftime("%Y-%m-%d")
)
sim_start_time_input = additional_parameters[
additional_parameters["parameter"] == "simulation_start_time"
]["value"].values[0]
st.session_state.sim_start_time_input = pd.to_datetime(
f"{st.session_state.sim_start_date_input} {sim_start_time_input}"
).strftime("%H:%M")
st.session_state.master_seed = additional_parameters[
additional_parameters["parameter"] == "master_random_seed"
]["value"].values[0]
st.session_state.activity_duration_multiplier = float(
additional_parameters[
additional_parameters["parameter"] == "activity_duration_multiplier"
]["value"].values[0]
)The sheet’s parameter names are not the session keys: number_of_runs becomes number_of_runs_input, simulation_duration_days becomes sim_duration_input, simulation_warm_up_duration_hours becomes warm_up_duration, master_random_seed becomes master_seed. Note also the unit conventions. The sheet is in days and hours, and model.py converts to minutes.
The manual path writes the same CSVs from widgets: see for example hems_rota.to_csv("actual_data/HEMS_ROTA.csv", index=False) at app/setup.py:653 and :919. The manual path is long and not covered further here.
Running
model.py has two branches, again keyed on platform.processor() == "". The sequential branch exists because joblib parallelism is not used on the hosted deployment.
app/model.py:152-178
if platform.processor() == "":
print("Running sequentially")
results = []
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,
)app/model.py:203-224
parallelProcessJoblib(
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,
master_seed=st.session_state.master_seed,
)
collateRunResults()This is where the unit boundary is. The model works in simulated minutes, so sim_duration_input (days) is multiplied by 24 * 60 and warm_up_duration (hours) by 60. The demand multiplier is a percentage in the UI and a fraction in the run API, hence / 100.0. The start date and time are two strings in session state and are combined into a single datetime.
The seeding is different between the branches. Sequentially the app passes master_seed + run as the per-run random_seed; in parallel it passes master_seed and lets parallelProcessJoblib derive the seeds. The full account, including why the two schemes give different results, is in Chapter 2. The parallel branch also calls collateRunResults() afterwards, whereas the sequential branch concatenates the returned DataFrames and writes Utils.RUN_RESULTS_CSV itself.
After the runs, model.py builds SimulationInputs, TrialResults and HistoricResults objects and fills five tabs. Those are the subject of Chapter 7. generate_quarto_report() in app/_app_utils.py shells out to quarto render on app/air_ambulance_simulation_output.qmd, and falls back to a disabled download button if the HTML does not appear.
sequenceDiagram
participant U as User
participant SP as setup.py
participant SS as session_state
participant AD as actual_data
participant MP as model.py
participant RUN as run functions
U->>SP: upload .xlsx
SP->>SP: parse_school_holidays (may stop)
SP->>AD: overwrite CSVs
SP->>SS: number_of_runs_input, master_seed, ...
U->>MP: press run
MP->>SS: read scalars
MP->>RUN: days to minutes, percent to fraction
RUN->>AD: model reads CSVs on build
RUN-->>MP: run_results.csv
Figure 2 is the use case from the top: the 24-hour rota lands in HEMS_ROTA.csv, the 30 days and seed 7 land in session state, and model.py passes 30 * 24 * 60 and seed 7 on.
Extending this
Add a scalar parameter (say a new multiplier). It has to be threaded through every layer:
- Add the key and default to
DEFAULT_INPUTSinapp/_state_control.py. - Add a widget in
app/setup.pyfor the manual path. - Add a row to the
additional_parameterssheet inapp/inputs/parameter_template.xlsxand in every file inscenarios/, plus a lookup block like the ones atapp/setup.py:286-322. The test README notes thatparameter_template_test.xlsxis not referenced by code, so it needs no change for tests. - Pass it from
app/model.pyin both therunSimcall and theparallelProcessJoblibcall. Missing one branch is the easy mistake, since only one runs on any given host. - Add the argument to the run functions in
air_ambulance_des/des_parallel_process.py. See Chapter 2.
Add a tabular input file (a new CSV in actual_data/):
- Create both
name.csvandname_DEFAULT.csv. - Add a read/write pair to
reset_to_defaults(). The TODO atapp/_state_control.py:80says this should loop over the folder, and the comment beside it states that every file in the folder should have a_DEFAULTcounterpart. - Add a sheet to the template and scenarios, read it in
setup.pyand add it toinput_data_files. - If the sheet needs validation, do it before the write loop, as for school holidays.
Add a predefined scenario: drop a filled-in template in scenarios/. It needs a unique name in scenario_details; tests/test_scenario_importing.py checks the sheets, columns, name, description and notes, and that each scenario differs from the blank template.
Tests. tests/test_scenario_importing.py checks file contents and runs the setup page headlessly with Streamlit’s AppTest. It overwrites the CSVs in actual_data while running and restores them afterwards, so do not run it in parallel with an app session on the same checkout. Run from the repository root:
Example usage (see tests/README.md)
pytest tests/test_scenario_importing.py
pytest -m quickThe second command comes from tests/README.md, which lists scenario importing among the quick tests.
Conventions the code expects.
- Launch from the repository root. Paths such as
actual_data/...,scenariosandapp/assets/...are relative to the working directory. - Pages import siblings with a
sys.pathworkaround (app/setup.py:7,app/model.py:20). - Pages call
setup_state()themselves rather than assumingapp.pyran first.
Sharp edges
- The CSVs are process-global. Two browser sessions on one server share
actual_data/. One user’s upload changes what the other’s run reads. Issue #101 (open as of 2026-10-19) concerns exactly this on hosted deployments. - Top-level script semantics. Streamlit reruns
setup.pyfrom the top on every interaction. While a file or scenario stays selected, the write loop atapp/setup.py:213-214runs again each time, rewriting the CSVs. - Only school holidays are validated before writing (see above).
- Seasonal demand keys are inert.
DEFAULT_INPUTScarriesspring_demand_multtowinter_demand_multanddemand_adjust_type, butmodel.pypasses onlyoverall_demand_multto the run functions. The code atapp/_app_utils.py:387has a TODO for seasonal adjustment. scenario_1_setandscenario_2_setdo nothing yet.set_scenario_1_params()andset_scenario_2_params()only show a toast and flip a flag (TODOs atapp/_state_control.py:131,:138,:143,:150).- The cloud heuristic is
platform.processor() == "". It is used inapp.py,_state_control.pyandmodel.py. A host that reports a processor string gets the parallel branch even if it cannot support it. - Duplicated work in reset.
service_schedules_by_modelis reset twice inreset_to_defaults(). Harmless, but a sign the function wants the loop its TODO describes.
Design notes
Documented.
- Changeable rotas and models in the app began in commit
02521a7(2025-02-25). - Resetting files on every new app load or refresh was added in commit
c82cb2f(2025-05-22), which is the origin of thefirst_loadblock. - Excel import began in
6c5cd6cand was refined in1fb4840; predefined scenarios arrived in2d34d73(all 2025-10-27).b675ac3(2026-10-15) fixed scenario uploading and reduced fragile file paths. - Commits
0338459andd1a9e83(2025-10-08/09) adjusted the relative-path approach and CSV loading to fix Streamlit Community Cloud problems;cfa31e0(2025-10-08) separately tried to fix a cloud indexing bug (see Chapter 7). - The demand adjustment parameter was temporarily removed in
f067ed7(2025-05-22) because it produced unexpected behaviour. That matches the seasonal-demand TODO and the unused keys above.
Inferred.
- The sequential branch presumably exists because the cloud host cannot use joblib workers; the code comment says “parallelisation will not work” there (
app/model.py:150). - Writing over the CSVs was presumably the smallest route to changeable rotas, because the model already reads its rota from CSV. Resetting on load presumably exists so that edited files do not persist between visits.
- Keeping
_DEFAULTcopies beside each working CSV, rather than regenerating from the template, suggests the aim was a reset that needs no spreadsheet parsing.
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.
- Hosted deployment problems: #130, #262 and #101.
- Scenario comparison inside the app is not built: #56. This is the open work behind the placeholder
set_scenario_*functions. - The start date picker has no limits, which interacts with school holiday detection: #74.
- Markers:
app/setup.py:199(the TODO “Consider whether the school holidays file needs to be added in to this”; it looks stale becauseschool_holidaysis already an entry ininput_data_files) andapp/setup.py:939(“Add to session state”, a commented-out TODO on the manual path).
All issues above were open as of 2026-10-19. See the issue tracker for the current state.
Conclusion
With parameters in session state and actual_data/, the next question is what a run does with them. Chapter 2 follows runSim and parallelProcessJoblib from the arguments built here.