Chapter 1: Streamlit App and Scenario Parameters

How the Streamlit app holds scenario parameters in session state, writes them over the CSVs in actual_data, and hands them to the run functions.

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 from DEFAULT_INPUTS in app/_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.

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
Figure 1: Where each kind of parameter lives between the setup page and the run

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 GitHub

The 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_value

setup_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 += 1

Incrementing 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:

  1. Validation happens before any write. parse_school_holidays (in air_ambulance_des/school_holidays.py) raises ValueError on a holiday that ends before it starts, and st.stop() halts the script run before the loop at the bottom touches a CSV. tests/test_scenario_importing.py checks that a bad file leaves actual_data unchanged.
  2. input_data_files is the sheet-to-file map. Keys are CSV stems in actual_data/. The callsign_registration_lookup entry drops vehicle_type and callsign_group columns 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.
  3. 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: Uploading a template and running it

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:

  1. Add the key and default to DEFAULT_INPUTS in app/_state_control.py.
  2. Add a widget in app/setup.py for the manual path.
  3. Add a row to the additional_parameters sheet in app/inputs/parameter_template.xlsx and in every file in scenarios/, plus a lookup block like the ones at app/setup.py:286-322. The test README notes that parameter_template_test.xlsx is not referenced by code, so it needs no change for tests.
  4. Pass it from app/model.py in both the runSim call and the parallelProcessJoblib call. Missing one branch is the easy mistake, since only one runs on any given host.
  5. 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/):

  1. Create both name.csv and name_DEFAULT.csv.
  2. Add a read/write pair to reset_to_defaults(). The TODO at app/_state_control.py:80 says this should loop over the folder, and the comment beside it states that every file in the folder should have a _DEFAULT counterpart.
  3. Add a sheet to the template and scenarios, read it in setup.py and add it to input_data_files.
  4. 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 quick

The 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/..., scenarios and app/assets/... are relative to the working directory.
  • Pages import siblings with a sys.path workaround (app/setup.py:7, app/model.py:20).
  • Pages call setup_state() themselves rather than assuming app.py ran 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.py from the top on every interaction. While a file or scenario stays selected, the write loop at app/setup.py:213-214 runs again each time, rewriting the CSVs.
  • Only school holidays are validated before writing (see above).
  • Seasonal demand keys are inert. DEFAULT_INPUTS carries spring_demand_mult to winter_demand_mult and demand_adjust_type, but model.py passes only overall_demand_mult to the run functions. The code at app/_app_utils.py:387 has a TODO for seasonal adjustment.
  • scenario_1_set and scenario_2_set do nothing yet. set_scenario_1_params() and set_scenario_2_params() only show a toast and flip a flag (TODOs at app/_state_control.py:131, :138, :143, :150).
  • The cloud heuristic is platform.processor() == "". It is used in app.py, _state_control.py and model.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_model is reset twice in reset_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 the first_load block.
  • Excel import began in 6c5cd6c and was refined in 1fb4840; predefined scenarios arrived in 2d34d73 (all 2025-10-27). b675ac3 (2026-10-15) fixed scenario uploading and reduced fragile file paths.
  • Commits 0338459 and d1a9e83 (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 _DEFAULT copies 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

NoteWhy the model makes simplifying assumptions

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 because school_holidays is already an entry in input_data_files) and app/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.