Code
source("main.R", echo = TRUE)This chapter covers how to prepare the inputs, run the model, find the results and check one run against another. The model is controlled through 00_config.R and the files in user_inputs/, so different services and scenarios can be run without editing the engine scripts.
00_setup_and_run.R, at the top of the repository, runs the whole workflow in order and then checks that nothing has been missed. Set RUN_PRESET at the top and run it from the repository root, in RStudio with source("00_setup_and_run.R", echo = TRUE) or from a terminal with Rscript 00_setup_and_run.R. Nothing in 00_config.R needs changing: each step runs in its own R session and gets its area, scenarios and run mode on the command line, and the first step to fail stops the run with its name.
RUN_PRESET |
When | Steps |
|---|---|---|
full_vm |
A full run on the virtual machine | packages; the data preparation scripts that need running; the three calibrations; England with every scenario; every ICB and Trust; the report inputs and results; the check |
prepared_inputs |
Given prepared inputs, away from the virtual machine | as full_vm, without the data preparation and report inputs |
after_pathway_change |
After changing the basecase pathway, quit rates or effects of quitting | the three calibrations, every run, the report results, the check |
after_other_change |
After changing costs, another scenario, a coverage panel or the report code | every run, the report results, the check |
report_only |
After changing only figures, tables or report text | the report results, the check |
check_only |
To see what is done and what isn’t | the check |
The steps run in this order, each after the ones before it:
| Step | What it does | What it makes |
|---|---|---|
packages |
renv::restore() |
the R packages at the versions in renv.lock |
prepare_inputs |
the data preparation scripts (below) | data/real/derived/ and data/base/derived/ |
calibrate_pathway, calibrate_history, calibrate_follow_up |
the three calibrations, in that order (“Calibrating the model” below) | values in scenarios_control_panel.csv and macro_trends_panel.csv; output/calibration_record.csv |
england |
main.R for England with every scenario, then its scenario comparison |
output/national/england/; files in output/for_report/ |
areas |
run_batch.R: every ICB and every Trust, each with AREA_SCENARIOS (00_config.R), its comparison, briefing and slides |
output/icb/, output/trust/; files in output/for_report/; output/batch_logs/ |
(after england or areas) |
the England briefing and slides, which compare the ICBs, so they are built after every ICB | output/for_report/briefings/, output/for_report/slides/ |
report_inputs |
the report’s chapters on hospital patients and smoking, and the appendix of conditions | report_ch1.rds, report_smoking.rds, reports/_tbl_ch1_conditions.qmd |
report_results |
the comparison with Ottawa, the results file with disclosure control, every figure and table | output/for_report/ |
check |
check_run_complete.R (below) |
output/run_status.csv |
render |
quarto render reports/report.qmd, after output checking; not in any preset |
reports/report.docx, .html |
With PREPARE_MODE “as_needed” (the default), the data preparation runs only the scripts whose outputs are missing or older than the outputs of the scripts they follow, and every script that follows one being run. For example, running the HES script again also runs the catchment weights, the smoking base grids and the deaths by cause, which read its outputs. Which script makes what, and which it follows, is listed in PREPARE_STEPS in R/run_records.R. A change to a data preparation script itself is not detected: after changing one, run it, or set PREPARE_MODE to “all”.
The areas step runs 36 ICBs and 129 Trusts, each with seven scenarios, so it takes some hours. It carries on past an area that fails and lists every failure at the end, with the logs in output/batch_logs/; run the step again after fixing the cause (to run only some areas, list them in BATCH_RUNS in run_batch.R).
check_run_complete.R is the last step of every preset, and can be run on its own at any time (Rscript check_run_complete.R, or RUN_PRESET <- "check_only"). It checks each stage in turn, says for each check OK, MISSING, OUT OF DATE or NOTE, and names the steps to run:
prepare_plan()). Files in data/real/derived/ that no script makes, left from earlier versions, are listed as a NOTE.output/calibration_record.csv. A later change to the control panel or the macro trends panel is a NOTE: it needs the calibrations again only if it changes the basecase pathway, quit rates or effects of quitting.run_record.csv), and the scenario comparison, briefing and slides were made after it.geo_areas.csv, with AREA_SCENARIOS.Every check, with what to run, is written to output/run_status.csv. The check stops with an error, listing the steps to run in order, if anything is MISSING or OUT OF DATE, so a run of 00_setup_and_run.R that ends with “Everything is complete and up to date” has done everything.
The Hospital Episode Statistics (HES) extract is held under data sharing agreement NIC-366216 and may only be processed on the heta_study virtual machine. Every file made from it, and every other file excluded on information governance grounds, lives under data/real/ and is never committed. Non-sensitive inputs live under data/base/. There is no synthetic or dummy version of the HES data: the model runs only where the real inputs are.
The data preparation scripts in scripts/01_data_prep_real/ are run once, in this order, and again only when a source changes:
| Order | Script | Makes |
|---|---|---|
| 1 | 00_construct_lags.R |
monthly recovery curves after quitting (data/base/derived/monthly_lags.csv) |
| 2 | 01_process_hes_data.R |
the hospital inputs for England, every ICB and every trust (data/real/derived/hes_inputs/), and, for England, the patients with each condition and those who died in their first admission (hes_condition_patients.csv, hes_index_deaths.csv), and the IHD patients by admission type, for the IHD smoking target (hes_ihd_admission_type.csv) |
| 3 | 02_process_population.R |
population by age, sex and IMD quintile |
| 4 | 03a_collapse_synthetic_population.R, 03b_build_catchment_weights.R, 03c_build_smoking_base_grids.R |
smoking prevalence in each hospital’s catchment, before calibration (data/real/derived/smoking_inputs/) |
| 5 | 04_process_mortality.R |
annual mortality rates (data/real/derived/ons_mortality_rates.csv) |
| 6 | 05_build_lifetime_values.R |
the life years, QALYs and NHS savings of each person who quits for life, by age band and sex, central and lower (data/base/derived/lifetime_values_per_quitter.csv), from user_inputs/lifetime_benefits_panel.csv; it runs again whenever the panel, its sources or the control panel change |
| 7 | 07_prep_relative_risks.R |
relative risks by condition, age band and sex |
| 8 | 08_process_pld_referrals.R |
referrals to the service by month and year from NHS England’s patient-level data, and the calibration targets (data/real/derived/pld_referrals_*.csv, pld_referral_targets.csv) |
| 9 | 09_process_mff.R (for the MFF option and the report’s illustration of it) |
the market forces factor payment index for every trust in trusts_in_scope.csv (data/real/derived/mff_by_trust.csv), from NHS England’s allocations spreadsheet in data/real/raw/; see “Local costs: the market forces factor” |
| 10 | 10_process_cause_deaths.R (after 2 and 5) |
each condition’s deaths a year per patient who has it, and the death rate from other causes (data/real/derived/condition_excess_mortality.csv, other_cause_mortality.csv), from the ONS deaths by cause file in data/real/raw/ (chapter 5, “Deaths after discharge”) |
main.R checks every input as it reads it (R/check_inputs.R). If a file is missing, or a column the engine needs is missing, blank or out of range, the run stops and the message names the file, the problem and the script that makes it.
Most users will run the model with inputs prepared on the virtual machine and passed to them after output checking, without access to the HES or patient level data themselves. They skip the data preparation and the calibration, and need, in the same folders as on the virtual machine:
| Files | What they hold |
|---|---|
data/real/derived/hes_inputs/<level>/ (admissions_stratified.csv, outcome_multipliers.csv, input_comorbidities.csv, geo_areas.csv) for national and each level they will run |
patients, admissions, comorbidities, unit costs and lengths of stay by area, condition, age group, sex and IMD quintile |
data/real/derived/smoking_inputs/<level>/smoking_base_grid.csv, for national and each level |
smoking prevalence in each hospital’s catchment before calibration |
data/real/derived/hes_ihd_admission_type.csv |
patients first admitted for ischaemic heart disease, elective and non-elective, which weight the IHD smoking target (chapter 3) |
data/real/derived/condition_excess_mortality.csv and other_cause_mortality.csv |
death rates after discharge, by condition and from other causes (chapter 5, “Deaths after discharge”) |
data/real/derived/pld_referrals_annual.csv and pld_referral_targets.csv |
NHS England’s referrals by year, shown against the model’s in Tab_X and Tab_X2, and the targets for the coverage calibration. Made from NHS England’s extract 67_017_tobacco_referrals_20260617.xlsx, put in data/real/raw/, by scripts/01_data_prep_real/08_process_pld_referrals.R (the prepare_referrals step of 00_setup_and_run.R), which needs nothing else |
user_inputs/ and data/base/ |
as in the repository, with the calibrated coverage and follow-up admissions factors already written into macro_trends_panel.csv and scenarios_control_panel.csv |
main.R checks every one of these as it reads it and names any that is missing. With them in place, set the area and scenarios and run main.R, or list areas in run_batch.R; then build the briefings, slides and report as below. Changing a scenario’s parameters, coverage rules or costs needs no recalibration. Recalibrating (after a change to the clinical pathway, the quit rates or recovery after quitting) needs only the prepared inputs and the referrals extract, so it can be done anywhere; a change to the inputs themselves needs the HES data and is done on the virtual machine. Changing a coverage rule constant needs the coverage calibration (calibrate_history) before the run.
| To change | Where | Then |
|---|---|---|
| What the service adds over ward care (each step’s value with and without the service) | the treatment and control rows of user_inputs/scenarios_control_panel.csv (chapter 4, “What the service adds”) |
as for pathway probabilities, below |
| Pathway probabilities and quit rates | user_inputs/scenarios_control_panel.csv, one column per scenario |
for the basecase, the three calibrations then main.R; for another scenario, main.R |
| NRT, staffing, salaries, unit costs, cash-releasing share | user_inputs/scenarios_control_panel.csv, in 2024/25 prices (chapter 6, “Prices”) |
run main.R |
| The discount rate | discount_rate in the control panel, the same in every scenario |
05_build_lifetime_values.R, then main.R |
| The lifetime value of quitting (Tier 2): life years by age at quitting, when they arrive, the share who would quit later anyway, QALYs per life year, central and lower | user_inputs/lifetime_benefits_panel.csv (chapter 6, “Changing the lifetime assumptions”) |
05_build_lifetime_values.R, then main.R |
| The price year | MODEL_PRICE_YEAR in 00_config.R, with the year’s index in data/base/raw/nhs_cost_inflation_index.csv and the control panel’s money values in that year’s prices |
05_build_lifetime_values.R, then main.R |
| Differences by age group and IMD quintile | user_inputs/equity_multipliers_panel.csv |
run main.R |
| Coverage rules for the scenarios (full ambition, disinvestment), and the MSF path for quit attempts | COVERAGE_* and MSF_METRIC_POINTS in 00_config.R |
the history calibration (MAIN_RUN_MODE <- "calibrate_history"), then main.R |
| An area’s own coverage | user_inputs/area_coverage_panel.csv |
run main.R for the area |
| Admissions growth, prevalence trend, relapse | user_inputs/macro_trends_panel.csv |
run main.R |
| Follow-up admissions | follow_up_admissions_factor_* in the control panel, set by the follow-up calibration |
the follow-up calibration, then main.R |
| The effect of quitting on a condition, and the evidence for it | user_inputs/post_diagnosis_effects.csv (chapter 5, “Effects of quitting in people who already have a disease”); effect_borrowing in the control panel |
the follow-up calibration, then main.R |
| Readmissions per smoker without the service that k1 and k2 are calibrated to | user_inputs/readmission_targets.csv (today the Ottawa control group) |
the follow-up calibration, then main.R |
| When deaths from a condition happen after discharge, and the most that can die of it in year 1 | user_inputs/excess_death_timing.csv (chapter 5, “Deaths after discharge”) |
the follow-up calibration, then main.R |
| The ONS deaths by cause | a new file in data/real/raw/, with FILE_CAUSE_DEATHS_RAW in 00_config.R pointed at it |
10_process_cause_deaths.R on the virtual machine, the follow-up calibration, then main.R |
| Thresholds and cash-releasing shares shown in the report | REPORT_WTP_THRESHOLDS, REPORT_CASHABLE_STEP in 00_config.R |
01_build_report_results.R |
| Disclosure control | SDC_* in 00_config.R |
every step that writes to output/for_report/ |
Every parameter in the control panel has a definition and a source in its last two columns. A change to the basecase should keep these up to date, and a new scenario needs a column in both panels and a coverage rule (see “Adding a scenario” below).
These are set in 00_config.R:
| Setting | What it does |
|---|---|
GEO_LEVEL |
"national", "icb" or "trust" |
ACTIVE_REGION |
the area within that level: "england", an ICB name such as "SOUTH YORKSHIRE", or a trust code such as "RHQ". The areas available are listed in geo_areas.csv in each level’s folder of data/real/derived/hes_inputs/ |
SCENARIOS_TO_RUN |
"all" for every scenario in scenarios_control_panel.csv, or a list of names such as c("Basecase"). A name that isn’t in the panel stops the run |
MAIN_RUN_MODE |
"full" to run the scenarios; "calibrate_pathway", "calibrate_history" or "calibrate_follow_up" to calibrate (see “Calibrating the model” below) |
MFF_MODE |
"off" (national costs), "relative" or "payment" (see “Local costs: the market forces factor” below) |
SMOKING_HEADLINE_SOURCE and the other settings in section 7c |
the smoking calibration (see chapter 3) |
From the repository root:
source("main.R", echo = TRUE)or Rscript main.R from a terminal. Don’t run it by sending lines to the console: if anything asks a question part way through, the console takes the lines that follow as the answer.
Each scenario takes a few minutes. The time taken by each stage is printed and saved in the run record.
Results go in one folder per area and scenario, so runs never overwrite each other:
| File | Contents |
|---|---|
output/<level>/<area>/<scenario>/results.rds |
every result table, as R objects. The figures, tables, slides and briefings are built from this |
output/<level>/<area>/<scenario>/<scenario>_HybridReport.xlsx |
the same tables, one sheet each, for reading |
output/<level>/<area>/<scenario>/<scenario>_VFM_Subgroups.csv |
subgroup economics |
output/<level>/<area>/<scenario>/run_check.csv |
the headline metrics, rounded to 3 significant figures, for comparing runs |
output/<level>/<area>/run_record.csv |
when the run happened, its settings, the time each stage took, and a checksum of every input and code file |
output/calibration_record.csv |
when each calibration finished, with a checksum of every input and code file at that moment |
output/run_status.csv |
the result of check_run_complete.R: every check, its status and what to run |
The calibrated smoking prevalence and the calibration factors are also written, each run, to data/real/derived/smoking_inputs/<level>/.
Everything under output/ stays on the virtual machine unless it has been through output checking. Results for ICBs and trusts rest on smaller numbers of patients than national results and need disclosure control before they leave.
Three things are calibrated, in this order (with the run itself as a fourth step), on a national run (GEO_LEVEL “national”, ACTIVE_REGION “england”). Each is a run of main.R with MAIN_RUN_MODE set in 00_config.R. The calibration modes prepare the same inputs as a full run, write their results to user_inputs/ and save no model results. Area runs use the national values.
MAIN_RUN_MODE <- "calibrate_pathway" and run main.R. R/calibrate_pathway.R sets quit_rate_scale so that supported quit attempts succeed as often as they did nationally in 2024 (20.2%, Agrawal et al.), and checks the share seen (60.2%) and supported attempts per identified smoker (22.3%) against the same data (chapter 4). It runs the pathway once and prints both.MAIN_RUN_MODE <- "calibrate_history" and run main.R. R/calibrate_history.R solves within-trust coverage for 2023, 2024 and 2025 so the model’s referrals match NHS England’s patient-level data (chapter 7), then sets coverage for every year and scenario from the rules in 00_config.R (COVERAGE_RULE_BY_SCENARIO; see “The national scenarios” below) and writes both coverage columns of macro_trends_panel.csv. It runs the pathway for the historical years only, a few times, and prints the fit.MAIN_RUN_MODE <- "calibrate_follow_up" and run main.R. R/calibrate_follow_up.R sets the follow-up admissions factors \(k_1\) and \(k_2\) so that readmissions without the service match the targets in readmission_targets.csv, today Ottawa’s control group (chapter 7), for the base case and for each scenario in FOLLOW_UP_OWN_FACTOR_SCENARIOS, and writes them into scenarios_control_panel.csv. Scenarios in FOLLOW_UP_NO_DECLINE_SCENARIOS get \(k_2\) equal to \(k_1\). It runs the 2026 cohort once for each of those scenarios and prints the factors.MAIN_RUN_MODE <- "full" and run main.R. Tab_X shows referrals against the targets and Tab_14b the model’s and Ottawa’s readmission rates (equal in years 1 and 2). Two checks follow, with nothing calibrated to them: Tab_14d the model’s deaths after discharge in each arm next to Ottawa’s, and Tab_14e the model’s quitters next to Ottawa’s and London’s, for admissions and deaths, with whether each lies inside the study’s interval.Repeat steps 1 to 3 after any change to the HES inputs, smoking calibration, relative risks, recovery curves, effects of quitting, quit rates or clinical pathway parameters. Each step depends on the ones before it through the quitters carried forward. 00_setup_and_run.R runs all three in order. scripts/04_reports/main_report/build_ottawa_report_inputs.R then writes the comparison with Ottawa and the sensitivity analyses for the report to output/for_report/report_ottawa.rds, with disclosure control applied.
model_development/compare_runs.R compares two result workbooks sheet by sheet and cell by cell. Set the two paths at the top of the script and source it. It prints, for each sheet, whether it is the same, the number of cells that differ and the largest difference, and writes the same table to output/compare_runs.csv. It reports counts and differences only, never values.
It is used in two ways. After a change that shouldn’t move any result, every sheet should be the same. After a deliberate change, it shows which tabs moved and by how much. The two run records show whether any input or code file differed between the runs.
Four scenarios tell the national story, following the ambitions of the CVD modernising services framework (MSF). Each is the same as the basecase from the index year (2019) to 2025 and differs only from the policy effect year (2026) to the time horizon (2045).
Scenarios diverge at the policy effect year. follows_basecase_before_policy_year in the control panel says, for each scenario, whether it uses the basecase’s parameters before the policy effect year. It is 1 for the service scenarios (the roll-out, improvement and minimum viable service scenarios), so they differ from the basecase only from 2026, and 0 for the sensitivity analyses, which change an assumption shared by every scenario (effects of quitting, readmissions, deaths, usual care, background quitting, prevalence, costs) and so apply it from the index year. For a scenario set to 1, main.R gives the engine the basecase’s clinical and quit success parameters and the yearly cost parameters (COST_PARAMS_BY_YEAR in 03_econ_engine.R) for the years before 2026. Every other global parameter applies to the whole run, so the run stops if such a scenario changes one (the MSF scenario’s quit_attempt_follows_msf acts only from 2026 and is allowed), or if its macro trends before 2026 differ from the basecase’s. Three differ only in coverage, set by rule from the current level, which is NHS England’s 92% of trusts with a service (COVERAGE_TRUST_CURRENT) and the calibrated 2025 within-trust coverage. The rules and their constants are in 00_config.R and R/coverage_paths.R.
| Scenario | Rule | What changes from 2026 |
|---|---|---|
Hold_Constant (the reference) |
hold |
coverage held at the current level |
Basecase (full ambition) |
full_ambition |
trust coverage rises to 95% (COVERAGE_TRUST_MAX) and within-trust coverage to 100% in equal steps to 2030, so 95% of smokers are reached from 2030 |
MSF_Planned_Increase |
full_ambition |
coverage as the base case, and quit attempts among patients seen rise in proportion to the MSF metric (below) |
Disinvest |
disinvest |
reach falls to half the current level in equal steps to 2028, then held: about 10% of the current trusts stop their service each year (COVERAGE_DISINVEST_TRUST_LOSS) and the rest of the fall is within trusts |
The MSF metric is supported quit attempts per inpatient who smokes: 22% now, 44% by 2029 and 54% by 2036 (MSF_METRIC_POINTS, straight lines between, then held). Its 22% is the national figure in the patient level data (22.3% in 2024), which counts the smokers each service records, so the metric is per smoker the service reaches and more coverage does not move it. In MSF_Planned_Increase (quit_attempt_follows_msf = 1 in the control panel) the national average of pr_quit_attempt_seen rises in proportion to the metric, keeping the differences by age, sex and IMD quintile on the odds scale (msf_attempt_params()), so supported attempts per smoker reached follow the ambition. Nothing else changes: assessments and the staff they need are as in the base case, so the scenario shows what meeting the ambition would deliver, not what it would cost to meet it.
The other scenarios use the full ambition rule. The single-lever improvement scenarios (MVS, SCREENING_BEST, ASSESSMENT_BEST, REFERRAL_BEST, PHARMA_BEST, BEST_ALL) change the clinical pathway. The sensitivity analyses are the range, Sens_Effects_Lower (lower: no condition borrows the effect of its nearest evidence) and Sens_FollowUp_NoDecline (higher: readmissions do not fall after the first year), with Sens_Effects_Upper (every capped condition borrows) beside them (chapter 5), nine on costs, Sens_Productivity_Low, Sens_Productivity_High, Sens_NRT_Low, Sens_NRT_High, Sens_Overhead_Low, Sens_Overhead_High, Sens_Consultant_Low, Sens_Consultant_High and Sens_Advisor_Band3 (chapter 6), and two on the comparator, Sens_Screening_Equal (usual care screens as many patients as the service) and Sens_Screening_Advice_Equal (and gives as much very brief advice) (chapter 4), two on background quitting, Sens_Background_Low (1% at 4 weeks) and Sens_Background_High (4.8%, which is 2% at 12 months) (chapter 4), two on the smoking prevalence trend from 2026, Sens_Prevalence_Static (no fall) and Sens_Prevalence_Faster (2.8% a year), set in macro_trends_panel.csv, four on the evidence for the effects of quitting, Sens_Evidence_Weaker and Sens_Evidence_Stronger (every study at its confidence limit nearer and further from no effect) and Sens_Stroke_Weaker and Sens_Stroke_Stronger (the stroke study alone), and one on death rates after discharge, Sens_Death_Rates_Lower (deaths from each condition halved) (chapter 5, “Sensitivity analyses of the effects of quitting”). Run them with Basecase for England; the report’s sensitivity table is built from them. To add a sensitivity analysis, add a column to scenarios_control_panel.csv and equity_multipliers_panel.csv, its rows to macro_trends_panel.csv (copy Basecase), its coverage rule to COVERAGE_RULE_BY_SCENARIO in 00_config.R, and its name to the list in scripts/04_reports/main_report/build_results_for_report.R.
scripts/04_reports/briefings/build_area_outputs.R makes, for the area in 00_config.R (or given with --level= and --area=), a Word briefing (about five pages for England, about four for an ICB or a Trust) and a slide deck in the University template (reports/assets/uos_slide_template.pptx). It first collects the area’s results with disclosure control applied (R/report_results.R) into output/for_report/results_<level>_<area>.rds, and builds both documents from that alone, so every number in them is already rounded; they go to output/for_report/briefings/ and output/for_report/slides/ for output checking. It needs Basecase, the lower and higher estimates (SCENARIO_RANGE_LOW, SCENARIO_RANGE_HIGH), the roll-out scenarios and the improvement scenario in AREA_BRIEFING_IMPROVEMENT (BEST_ALL) run for the area. What each leads on depends on the audience. For England (NHS England and DHSC, about five pages and 16 slides): one year of the service; value for money from the Trusts’ side (Tier 1) and the health system’s (Tier 2), judged against NICE’s threshold, with the figure of how much of the lifetime benefit must be realised; whether the value is cash for Trusts, ICBs and the NHS as a whole; the roll-out choices and the cash flow under each; deaths under 75; the extra quits and admissions avoided by IMD quintile; the lowest, median and highest ICB on four measures beside England; support after discharge; the pathway rates; and what is not counted (build_national_briefing(), tbl_national_perspectives() and tbl_national_icb_range() in tbl_system.R). The England briefing reads every ICB’s results file (output/for_report/results_icb_<code>.rds), so it is built after the ICBs and stops if one is missing. For an ICB, the local system (the ICB and local authorities, about four pages and 14 slides, with no table by Trust): one year of the service with its cost by who pays it (Trusts, specialist support after discharge, ward staff time), the admissions and bed days avoided in two and five years, the deaths avoided (all and under 75) and the share of admissions avoided for heart disease and stroke, the extra quits and admissions avoided by IMD quintile (Core20PLUS5), lifetime savings and QALYs, the roll-out from 2026 to 2030 year by year and under each roll-out scenario, support after discharge, the result at best practice, the pathway rates the results rest on, and what is not counted (build_system_briefing(), scripts/04_reports/tables/tbl_system.R). A Trust’s briefing and slides keep to the Trust’s own budget (Tier 1): what the service costs the Trust and the staff it needs, what it does, the admissions and bed days avoided in two and five years (net of the admissions of patients alive because they quit, with both parts shown), the deaths avoided within two and five years, the value of those admissions against the cost and the share that would need to release cash to cover it, the cost per unit counted three ways (per quit with specialist support after discharge, per quit with the service, per extra quit), the result with every stage at best practice (the best published results for assessment and registration, the targets for screening and NRT), the patients treated from 2026 to 2030 as the service grows and what they avoid by the end of 2030, the model’s pathway rates for the Trust, and what the results do not count, including specialist support after discharge, which may be a cost to the Trust but isn’t included (R/report_results.R report_trust_view_for(), scripts/04_reports/tables/tbl_trust.R). The key messages, figures and headings are set in the script (national_key_messages(), system_key_messages(), trust_key_messages(), area_figures()). The notes say whether the area’s own coverage was used (see “Coverage for an ICB or trust”).
After running the scenarios for an area, scripts/04_reports/compare_scenarios.R writes output/<level>/<area>/scenario_comparison.xlsx, unrounded, for use on the virtual machine, and the same tables with disclosure control applied to output/for_report/comparison_<level>_<area>.rds. It shows, for the forward programme from 2026, each scenario’s activity, admissions avoided (over 3 and 10 years and in total, all conditions and cardiovascular, kidney and metabolic conditions), deaths avoided within five years of treatment (all ages and under 75), trust view, system view and supported quit attempt rates, then each scenario’s difference from Hold_Constant, extra admissions avoided per 1,000 extra supported attempts, and the cost per extra QALY against the reference. The “Annual budget” sheet gives each scenario’s cost to trusts in each year from 2026, in today’s prices and not discounted, with reach and specialist assessments: the yearly budget at current reach (Hold_Constant), at the MSF level and at full ambition (Basecase from 2030).
Supported quit attempts are quit attempts by patients seen by a tobacco dependence advisor (Agrawal et al.’s outcome 1). They are reported two ways, by year, in Tab_MSF_Supported_Attempts: among identified smokers the service reaches, which is comparable with the patient level data (22.3% in 2024, which only cover patients the service records) and is the MSF metric, and among all identified smokers in the area’s hospitals, which rises with coverage.
run_batch.R runs main.R for a list of areas, one after another, then the scenario comparison and the briefing for each. Set the areas in BATCH_RUNS (level and area code, one row per area; all_areas("icb") gives every ICB) and the scenarios in BATCH_SCENARIOS at the top of the script, then run it with source("run_batch.R", echo = TRUE). Each step runs in its own R session with Rscript, because main.R clears the workspace when it starts, and writes a log to output/batch_logs/. An area that fails does not stop the others; the batch stops with an error at the end that lists every failure, and batch_summary.csv shows what ran.
The batch passes the area and scenarios to main.R on the command line, which replaces GEO_LEVEL, ACTIVE_REGION and SCENARIOS_TO_RUN in 00_config.R. One area can be run the same way from a terminal:
Rscript main.R --level=icb "--area=SOUTH YORKSHIRE" --scenarios=Basecase,Hold_Constant
Anything else on the command line stops the run. Calibration is not run in a batch: calibrate first, on England, with MAIN_RUN_MODE set in 00_config.R.
It costs more to provide the same NHS care in some places than in others, mainly because of differences in pay, land and buildings. NHS England measures these unavoidable differences with the market forces factor (MFF), one value for each NHS trust. It comes in two versions that differ only in scale:
The model’s costs are national: admissions avoided are costed at national Payment Scheme prices and staff at national pay rates (PSSRU). The MFF option lets you cost an ICB or trust at its local costs instead.
An area’s MFF is the average of its trusts’ payment indices, weighted by their admissions (hes_inputs/trust/geo_areas.csv). England’s MFF is the same average over every trust. MFF_MODE in 00_config.R then says how it is used:
MFF_MODE |
Staff pay and ward staff time | Unit costs of admissions avoided | Use it for |
|---|---|---|---|
"off" (default) |
national | national | England, and any comparison between areas on the same footing |
"relative" |
x area MFF / England MFF | x area MFF / England MFF | a Trust’s or ICB’s business case: its budget and savings in local terms |
"payment" |
x area MFF / England MFF | x area MFF | valuing avoided admissions at what the trust is actually paid for them |
With "relative", the area’s costs are scaled by how much more or less expensive it is than England as a whole. This works like the underlying index, with England’s average (over the admissions in the model) set to 1, so England’s own results don’t change. An ICB whose MFF is 4.5% above England’s has its staff pay and the cost of the admissions it avoids 4.5% above the national values.
With "payment", admissions avoided are valued at the trust’s own price, national price x payment index, as the trust is paid. Because every payment index is 1 or more, this raises the savings everywhere, including England’s (by England’s average index). Pay is still scaled relative to England, because PSSRU pay is already a national average.
Some values are never adjusted: NRT (a national price), specialist support after discharge, lifetime NHS savings and QALYs. Numbers of patients, quits, admissions avoided and bed days don’t change either: the MFF changes what things cost, not what happens.
These numbers are made up to show the arithmetic. An ICB has two trusts: trust A has a payment index of 1.15 and 30,000 admissions, trust B 1.09 and 70,000. The ICB’s MFF is (30,000 x 1.15 + 70,000 x 1.09) / 100,000 = 1.108. If England’s MFF is 1.060, the relative factor is 1.108 / 1.060 = 1.045.
At national costs, one year of the service costs the ICB’s trusts GBP 2.0m, of which GBP 0.3m is NRT, and saves GBP 1.5m in avoided admissions over five years.
"relative": cost = (2.0 - 0.3) x 1.045 + 0.3 = GBP 2.08m; savings = 1.5 x 1.045 = GBP 1.57m. Savings per pound go from 0.75 to 0.76."payment": cost as for "relative", GBP 2.08m; savings = 1.5 x 1.108 = GBP 1.66m.So local costs change the size of the budget and the savings more than the balance between them. Savings per pound rise slightly in high-cost areas because NRT, which costs the same everywhere, is a smaller part of the cost.
data/real/raw/h-market-forces-factor-2627-to-2829-version-1.xlsx (from www.england.nhs.uk/allocations/). It uses the provider_mff sheet, which has a row for each trust: its code (Trust), name and Payment MFF index. If NHS England publishes a newer version, save it in the same folder and change FILE_MFF_RAW (and, if the layout has changed, SHEET_MFF_RAW, MFF_RAW_HEADER_ROW, MFF_RAW_CODE_COL and MFF_RAW_VALUE_COL) in 00_config.R, and MFF_SOURCE_LABEL to say what it is.Rscript scripts/01_data_prep_real/09_process_mff.R (it is also part of the prepare_inputs step of 00_setup_and_run.R). It writes data/real/derived/mff_by_trust.csv, one payment index for each trust in trusts_in_scope.csv, and prints the range of values. If a trust in scope isn’t in the spreadsheet, usually because it has merged, the script stops and names it. Add a row for it to data/real/raw/mff_code_map.csv (columns trust_code, mff_code, note), giving the code to use instead, for example the trust it merged into, and run the script again.MFF_MODE <- "relative" (or "payment") in 00_config.R. For a single run you can give it on the command line instead: Rscript main.R --level=icb "--area=SOUTH YORKSHIRE" --mff=relative. run_batch.R uses the setting in 00_config.R, so set it there for a batch, and run every ICB with the same mode.00_setup_and_run.R passes --mff=off to the calibration steps, and main.R stops if a calibration mode is run with the MFF on."off" before running England for the report, unless you mean England’s savings to be at payment index prices.Runs with the MFF on write to the same folders as runs without it, so a run replaces the previous run for that area and scenario. Keep a copy of the earlier results if you want both.
MFF (relative): SOUTH YORKSHIRE MFF 1.0234, England 1.0612; pay x 0.9644, unit costs of admissions x 0.9644.Tab_Area_MFF in the results (and the Excel workbook) records the mode, both MFFs, the two factors, the number of trusts and the source.The report’s chapter on Trusts and ICBs shows each ICB’s MFF relative to England’s (fig_mff) and one year of the service for each region at national and at local costs (tbl_mff). build_results_for_report.R works these out from the ICB results and mff_by_trust.csv, whatever mode the ICBs were run with: it takes the run’s own factors off to get national costs, then puts the relative factor on. This is exact, because the factors multiply only pay (all of the cost to Trusts except NRT) and the unit costs of admissions (all of the savings from admissions avoided). It needs 09_process_mff.R to have been run and every ICB to have Basecase results; otherwise the report says it is not yet available.
Everything the model produces is built from HES, so every output is treated as disclosive, modelled or not, and only output/for_report/ leaves the virtual machine, after output checking. R/disclosure_control.R holds the rules, from the HES Analysis Guide: counts of 1 to 7 are suppressed and other counts rounded to the nearest 5 (SDC_SUPPRESS_MAX, SDC_ROUND_TO); rates and means need at least 10 records behind them (SDC_MIN_N_FOR_MEAN); money is rounded to the nearest 1,000 GBP (SDC_MONEY_ROUND_TO). A modelled value counts as 1 to 7 if it rounds to one. QALYs and life-years are treated as counts.
Every table written to output/for_report/ goes through sdc_table() or sdc_metric_rows(), which stop if any column or metric has no rule, so a new output can’t leave without one. Each file written or copied there adds a line to output/for_report/disclosure_log.csv: when, what, how many cells were suppressed, the rules and the results it came from, with blank checked_by and checked_on columns for the output checker. No secondary suppression is applied, because every count is rounded to 5; the checker looks for totals that would let a suppressed cell be worked out.
The report (reports/report.qmd) reads only files in output/for_report/, so it can be rendered away from the virtual machine once they have been checked. They are made in two steps:
scripts/04_reports/main_report/00_build_report_inputs.R, after the data preparation: the chapters on hospital patients and smoking, and the appendix of conditions.scripts/04_reports/main_report/01_build_report_results.R, after main.R has been run for England with every scenario (and run_batch.R for every ICB with Basecase, for the ICB chapter): the scenario comparison, the comparison with Ottawa, the results file (build_results_for_report.R, which applies disclosure control), then every figure (scripts/04_reports/figures/make_figures.R) and table (scripts/04_reports/tables/make_tables.R) from it.Each figure is a function in its own fig_*.R file in scripts/04_reports/figures/, drawn in the house style in figure_style.R (colours for each condition group, fonts, fixed sizes) and saved as PNG and SVG. Each table is a function in a tbl_*.R file in scripts/04_reports/tables/, formatted as text with the shared number formats in table_style.R; suppressed values show as *. To change a figure or table, change its function and rerun make_figures.R or make_tables.R; to add one, write a new function and add it to the list in the wrapper.
“One year of the service” in the report and briefings is the patients treated in 2026: that year’s cost to Trusts and the admissions they avoid in the five years after treatment (Tab_20_Cohort_Condition_Matrix and Tab_21_Cohort_Pathway in the results). The forward programme is the service from 2026 to 2045.
Set require_results <- TRUE (and require_ch1, require_smk) at the top of the report for the final render, so that a missing file stops it rather than showing “not yet available”.
Before a final render:
main.R for England with every scenario, and run_batch.R for every ICB, with the same code and inputs (compare the run records with model_development/compare_runs.R if in doubt).00_build_report_inputs.R and 01_build_report_results.R.output/for_report/ checked against disclosure_log.csv and fill in its checked_by and checked_on columns.require_ flags to TRUE and render with quarto render reports/report.qmd.Rscript scripts/04_reports/check_doc_extracts.R from the repository root. It stops if a code extract in the documentation or the report no longer matches the code it quotes.A scenario is a column of user_inputs/scenarios_control_panel.csv: every column other than Category, Parameter_Name, Arm, Definition and Source is a scenario, and each row is a parameter of the clinical pathway, quit success or costs. scripts/00_build_scenarios.R turns the panel, with the odds ratios in equity_multipliers_panel.csv (chapter 7a), into the parameters each scenario runs with. Coverage by year is in user_inputs/macro_trends_panel.csv, set by a rule for each scenario (below).
user_inputs/macro_trends_panel.csv holds, for each scenario, one row per calendar year from 2019 to 2045 (and the years of follow-up after it) with the two measures of coverage and the background trends.
Trust_Coverage_Pct: the share of the trusts in scope with a live service in the year.Within_Trust_Coverage_Pct: in those trusts, the share of eligible inpatients the service reaches.Their product is the share of all eligible inpatients the service reaches. If 80% of trusts have a service and it reaches half of the eligible inpatients in each, the service reaches 40%. The engine works out the product; it is not an input:
# Extract from scripts/03_model_engine/04_master_loop.R, add_reach()
macro[, Reach := Trust_Coverage_Pct * Within_Trust_Coverage_Pct]Everything driven by activity, from the patients screened and assessed to the quits, admissions avoided and the costs of staff and NRT, scales with the product. The senior management cost a trust carries for running a service at all (band 8a) scales with Trust_Coverage_Pct alone, so opening services in more trusts adds that cost even where reach within them is low.
The Phase column marks the two periods.
A_historical (2019 to 2025). Trust coverage is NHS England’s share of trusts with a live service, by financial year, turned into calendar years (TRUST_COVERAGE_BY_FY). Within-trust coverage for 2023 to 2025 is calibrated (step 2 of “Calibrating the model”) so that the model’s referrals, the admissions of identified smokers within reach, match the referrals in NHS England’s patient-level data; for 2019 to 2022 it is the reach assumed in earlier versions (HISTORY_REACH_ASSUMED). This period is the same in every scenario.B_forward (2026 to 2045). Each scenario’s coverage follows its rule, so scenarios differ only from the policy effect year.Only the product is fixed by the referral data. A different history of trust coverage would move within-trust coverage the other way, leaving the product, and so the benefits, unchanged; only the timing of the senior management cost would change.
Coverage from 2026 is set by rule, not typed into the panel: the history calibration writes both coverage columns for every scenario and year. To add or change a trajectory:
R/coverage_paths.R (hold, full_ambition, disinvest) and change its constants in 00_config.R, or add a rule there. Disinvestment, for example, takes reach to COVERAGE_DISINVEST_REACH_SHARE of the current level by COVERAGE_DISINVEST_END_YEAR, with COVERAGE_DISINVEST_TRUST_LOSS of the current trusts closing their service each year, so the senior management cost falls as they close.COVERAGE_RULE_BY_SCENARIO. Every scenario must have one, or the calibration stops.Coverage typed by hand into macro_trends_panel.csv is overwritten the next time the history calibration runs.
The macro panel holds the national coverage, and by default an ICB or trust run uses it. An area that knows its own coverage can have a row in user_inputs/area_coverage_panel.csv, and its runs then use that instead. The panel ships with a header row only.
| Column | What to put |
|---|---|
Geo_Level |
icb or trust |
Area |
the area’s code, as in geo_areas.csv for that level (the same as ACTIVE_REGION) |
Trust_Coverage_Current |
the share of the area’s trusts with a live service now; 1 for a trust |
Within_Trust_Coverage_Current |
the share of identified smokers the area’s services reach now; blank to use the national level (the calibrated 2025 value) |
Go_Live_FY |
trusts only, optional: the year the financial year in which the service went live starts in (2022 for 2022/23), from 2018 to 2025 |
Source |
where the figures come from, which is printed in the briefing |
A run for an area with a row works out the area’s coverage when it starts (set_area_coverage() in R/coverage_paths.R), so there is nothing to calibrate:
COVERAGE_TRUST_CURRENT; for a trust with a go-live year it is 0 before that financial year and 1 from it, turned into calendar years like the national figures. Both are capped at 1.Every row is checked when an area is run: levels, codes that exist, coverage above 0 and at most 1 (an area must have a live service now), go-live years in range and a source on every row. A mistake stops the run and names the row. An area with no row is run on the national coverage, which the run prints in capitals, and the briefing says so in its notes. Every run records which coverage it used in Tab_Area_Coverage.
The area’s history is not calibrated to its own referrals; it keeps the national shape. The area’s referrals can still set its current within-trust coverage, because within a year referrals are in proportion to reach: run the area on the national coverage, then take the national 2025 within-trust coverage, times the area’s 2025 referrals in NHS England’s patient-level data over the model’s 2025 referrals for the area (Tab_X2_Referrals_By_Year), times the national 2025 trust coverage over the area’s. The patient-level data undercount referrals where trusts did not submit every month (chapter 7).
| Column | Meaning | Usual handling |
|---|---|---|
Adms_Growth_Pct |
annual growth in hospital admissions (0.015 is 1.5% a year) | the same in every roll-out scenario |
Prev_Change_Pct |
annual change in smoking prevalence (-0.005 is a fall of 0.5% a year), from 2026 | the same in every roll-out scenario; Sens_Prevalence_Static (0) and Sens_Prevalence_Faster (-0.028) change it |
Relapse_Rate |
annual relapse to smoking among those quit for more than 12 months | the same in every roll-out scenario |
Year |
model year (1 is 2019), the engine’s join key | made from Calendar_Year; don’t edit |
Phase |
A_historical (to 2025) or B_forward (from 2026) |
a label only |
Keeping these the same across the roll-out scenarios means any difference in their results comes from coverage alone.
The three files that define a scenario have different layouts. macro_trends_panel.csv has a block of rows for each scenario, one row per calendar year, tagged by the Scenario column. scenarios_control_panel.csv and equity_multipliers_panel.csv have a column for each scenario. To add one, for example a roll-out scenario that differs from Basecase only in coverage:
scenarios_control_panel.csv: add a column with the new name. To vary only coverage, copy the Basecase column; to vary coverage with a better pathway, copy a ..._BEST column.equity_multipliers_panel.csv: add a column with the new name, again copying Basecase. Every scenario needs a column, and the build stops if one is missing; a column of 1s means no differences by age, sex or deprivation.macro_trends_panel.csv: add a block of rows under the new name (copy Basecase).00_config.R: give the scenario a coverage rule in COVERAGE_RULE_BY_SCENARIO, then run the history calibration, which writes its coverage.main.R with the scenario in SCENARIOS_TO_RUN.