The model, stage by stage

A technical guide for checking each element

Author

Adam Dennett, UCL Centre for Advanced Spatial Analysis

Published

October 16, 2026

Abstract

This document takes the model the simulator runs and opens it up one element at a time. Each section states what a term is, where its data comes from, exactly how it is computed, what it looks like for a worked example, the checks that can be run against it, and what it assumes. It is written to be audited: every number is produced by the code in this repository, and every step should be reproducible by hand.

1 What this document is

The simulator runs one model. Everything in the strategic view, and every scenario in it, is that model with different settings. This guide takes it apart in the order the model is written, so that each piece can be checked on its own.

\[ T_{ij}^{\,y} \;=\; A_i^{\,y}\, O_i^{\,y}\, W_j\, c_{ij}^{-\beta}\, e^{\gamma_{h(i)} \cdot \mathbb{1}[\,i \in \text{catchment}(j)\,]}\, C_j^{\delta}, \qquad A_i^{\,y} = \frac{1}{\sum_k W_k\, c_{ik}^{-\beta}\, e^{\gamma_{h(i)} \mathbb{1}[\cdot]}\, C_k^{\delta}} \]

Symbol Meaning Section
\(T_{ij}\) children from neighbourhood \(i\) modelled as attending school \(j\) the interior of the matrix
\(O_i\) children in neighbourhood \(i\) entering Year 7 §2
\(W_j\) how attractive school \(j\) is, net of where it stands §3
\(c_{ij}\) routed walk-and-bus journey time from \(i\) to \(j\) §4
\(\beta\) distance decay §5
\(\gamma_h\) catchment term, fitted for each catchment \(h\) §6
\(C_j\), \(\delta\) competing destinations §7
\(A_i\) balancing factor: every neighbourhood places all of its children §8
\(y\) entry year, carried as a superscript §2.7

Two further stages are applied to the \(T_{ij}\) this produces: the admission-number ceiling with the council’s oversubscription priorities (§9), and the re-offering of the children it refuses (§10).

NoteThe year is the only dynamic element, and it enters in one place

Nothing in the equation carries a time subscript except \(O_i\). Journey times, attractiveness, catchment terms and admission numbers are all held at a single point in time, and the year chosen on the simulator’s slider changes only the size of each neighbourhood’s cohort (§2.7). Whether that is the right treatment — and what an explicit time dimension would have to carry — is a question this guide returns to once the static terms are set out.

Show code
ex_zones <- zc %>% arrange(desc(Oi)) %>% slice(1:4)
sch4 <- c("Dorothy Stringer School", "Varndean School",
          "Longhill High School", "Cardinal Newman Catholic School")
data.frame(
  Neighbourhood = c(sub("[|].*$", "", ex_zones$zone), "$W_j$"),
  `Dorothy Stringer` = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[1]]])),
  Varndean = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[2]]])),
  Longhill = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[3]]])),
  `Cardinal Newman` = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[4]]])),
  `$A_i$` = c(rep("?", 4), ""),
  `$O_i$ (2026)` = c(sprintf("%.1f", ex_zones$Oi), ""),
  check.names = FALSE) %>%
  knitr::kable(align = "lccccrr")
The shape of the model, for four neighbourhoods and four schools. Sections 2 and 3 fill in the two margins shown; the rest follow, and the interior cells come last.
Neighbourhood Dorothy Stringer Varndean Longhill Cardinal Newman \(A_i\) \(O_i\) (2026)
E01016901 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 29.8
E01016880 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 27.1
E01016979 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 25.5
E01016997 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 24.3
\(W_j\) 0.55 1.03 0.10 3.27

The interior of that matrix is what the model produces. The margins are what it is given, or what it fits. \(O_i\) is the one margin that is a count of real people, and it is where this guide starts.

2 \(O_i\): the children in each neighbourhood

2.1 What it is

\(O_i\) is the number of children living in zone \(i\) who will enter Year 7 at a state secondary school in the year being modelled. The model is production-constrained on it, which means that whatever else happens,

\[\sum_j T_{ij} = O_i \quad \text{for every } i.\]

Every child is placed somewhere. Nothing is created and nothing is lost, which is why \(O_i\) has to be right before anything else can be.

2.2 What a zone is

A zone is an LSOA cut by catchment, keyed as LSOA | pre-2024 catchment | current catchment. The city has 195 zones drawn from 165 LSOAs.

The cut exists because the 2024 boundary change runs through LSOAs. Assigning a whole LSOA to whichever catchment contains its centroid would register only a fraction of the children the redraw actually moves. So every postcode in the ONS Postcode Directory is assigned a catchment on each of the two maps by point-in-polygon, and the LSOA is split accordingly. Carrying both maps in the key means the two regimes sit on identical geography and can be compared without rebuilding anything.

Show code
z %>%
  filter(lsoa %in% c("E01016862", "E01016863")) %>%
  transmute(Zone = zone, LSOA = lsoa, `Pre-2024` = catch_pre2024,
            `Current map` = catch_optionZ, Postcodes = n_pcd,
            `$O_i$ (2026)` = sprintf("%.1f", Oi)) %>%
  knitr::kable(align = "llllrr")
Two LSOAs on the Longhill / Stringer-Varndean boundary, each split into two zones: the same neighbourhood, a different catchment on the current map.
Zone LSOA Pre-2024 Current map Postcodes \(O_i\) (2026)
E01016862|DS_Varndean|DS_Varndean E01016862 DS_Varndean DS_Varndean 10 1.7
E01016862|DS_Varndean|Longhill E01016862 DS_Varndean Longhill 27 4.3
E01016863|DS_Varndean|DS_Varndean E01016863 DS_Varndean DS_Varndean 1 0.2
E01016863|DS_Varndean|Longhill E01016863 DS_Varndean Longhill 35 8.4

2.3 Where the numbers come from

Input Source What it supplies
Ward population by single year of age, mid-2022 ONS ward-level mid-year population estimates (sapewardstablefinal.xlsx) how many children of each age live in each ward
LSOA population by single year of age ONS lower super output area mid-year population estimates (lsoa_age_11_props_totals.xlsx) how a ward’s children divide between its LSOAs
Postcode directory, February 2024 ONS Postcode Directory, ONS Open Geography Portal which ward and LSOA each postcode is in
Catchment boundaries Brighton & Hove City Council, school admissions — the option Z map in force from 2026 entry, and the map it replaced the catchment half of the zone key
Year 7 offers, 2024 Allocation factsheet, Year 7 places September 2024 the state-sector share
LSOA boundaries ONS Open Geography Portal drawing, and joining the age data to zones

None of it is pupil-level. The smallest thing any of these sources describes is a postcode count or an LSOA age band.

2.4 How \(O_i\) is built, step by step

The code is public/R/01_open_inputs.R in the private bundle, sections 1b and 1c. Its result travels to the simulator as data/open_inputs.rds.

Step 1 — ages become entry years. A child aged \(a\) in the mid-2022 estimates enters Year 7 in \(2022 + (11 - a)\). Ages 0 to 11 therefore give entry years 2022 to 2033, every one of them children ONS has already counted rather than births anyone has projected.

Step 2 — two extrapolated years. Children entering in 2034 and 2035 were not born when the mid-2022 estimate was taken. They are a linear continuation of the five most recent observed cohorts, 2029 to 2033, and are flagged as extrapolated wherever they appear.

Step 3 — resident children become state-sector demand. Not every resident child takes a state Year 7 place: some go to independent schools, some leave the city. A single constant carries that:

\[\text{state share} \;=\; \frac{\text{Year 7 offers made, 2024}}{\text{resident children, 2024 entry}} \;=\; 0.850\]

taken from the council’s published offer total against the ONS cohort of the same year.

Step 4 — a ward is divided between its zones. ONS publishes the age detail at ward level, so each ward’s children are shared out between the zones in it. The weight for a zone is its LSOA’s age-11 population share, divided between the zones that LSOA is cut into by their postcode counts:

\[u_i \;=\; \underbrace{p_{\ell(i)}}_{\text{LSOA age-11 share}} \times \underbrace{\frac{n_i}{\sum_{k \in \ell(i)} n_k}}_{\text{postcodes in this zone}}, \qquad O_i^{\,2026} \;=\; \text{ward children}_{w(i)} \times \frac{u_i}{\sum_{k \in w(i)} u_k} \times \text{state share}\]

where \(\ell(i)\) is the zone’s LSOA, \(w(i)\) its ward and \(n_i\) its postcode count. 195 of the 209 zones use the ONS age share; the 14 in the expansion area outside the city fall back to postcode counts alone.

NoteWhy not divide a ward by postcode count

Splitting a ward’s children by how many postcodes each LSOA holds assumes every postcode contains the same number of eleven-year-olds. Family housing, student housing and retirement housing differ enormously on that, and the difference runs along exactly the lines catchment boundaries follow. An earlier version of this work used postcode counts between LSOAs, and the deprivation contrast between the two halves of a paired catchment was almost entirely smoothed away. Postcode counts do the narrower job above — dividing one LSOA between its own fragments, where nothing finer is published.

ImportantCorrected: a split LSOA used to be counted twice

Until this version, each zone of a split LSOA inherited the whole LSOA’s age-11 share as its weight. Ward and city totals were unaffected, because the denominator grew too, but inside a ward a split LSOA took about 1.7 to 1.9 times the share its population implied and the unsplit LSOAs around it took correspondingly less. The splits sit exactly on the catchment boundaries, so the error was concentrated in the neighbourhoods these questions turn on — Whitehawk and Kemptown among them.

The weight is now divided between a split LSOA’s zones by their postcode counts, as in the formula above. The table below is the check that used to fail.

Show code
chk <- z %>% filter(!is.na(prop)) %>%
  group_by(ward) %>% mutate(zone_share = Oi / sum(Oi)) %>% ungroup() %>%
  group_by(ward, lsoa) %>%
  summarise(prop = first(prop), zone_share = sum(zone_share),
            n_zones = n(), .groups = "drop") %>%
  group_by(ward) %>% mutate(prop_share = prop / sum(prop)) %>% ungroup() %>%
  mutate(ratio = zone_share / prop_share)
chk %>%
  group_by(`Zones the LSOA is split into` = n_zones) %>%
  summarise(LSOAs = n(), `Median ratio` = sprintf("%.2f", median(ratio)),
            Lowest = sprintf("%.2f", min(ratio)),
            Highest = sprintf("%.2f", max(ratio)), .groups = "drop") %>%
  knitr::kable(align = "rrrrr")
Each LSOA’s modelled share of its ward against the share its age-11 population implies, grouped by how many zones the LSOA is split into. A ratio of 1 is agreement.
Zones the LSOA is split into LSOAs Median ratio Lowest Highest
1 160 1.00 0.02 1.53
2 16 1.00 0.47 1.53
3 1 0.98 0.98 0.98

223 of the 2,482 children in the model live in a split LSOA, so the correction moves children between zones inside those wards without changing any ward or city total.

Show code
pcd <- readr::read_csv(here::here("data", "zone_postcode_points.csv"),
                       show_col_types = FALSE)
# The clearest example to draw: the split LSOA with the most postcodes on
# its smaller side, so neither half is a handful of points.
pick <- pcd %>% count(lsoa, catch_optionZ) %>% group_by(lsoa) %>%
  filter(n() == 2) %>% summarise(smaller = min(n), .groups = "drop") %>%
  slice_max(smaller, n = 1) %>% pull(lsoa)
pp <- pcd %>% filter(lsoa == pick) %>%
  st_as_sf(coords = c("easting", "northing"), crs = 27700) %>% st_transform(4326) %>%
  mutate(Catchment = unname(CATCH_LABELS[as_boundary_catchment(catch_optionZ)]))
ls_one <- bh_data("lsoa.geojson") %>% filter(lsoa21cd == pick) %>% st_transform(4326)
catch_sf <- bh_data("catchments_current.geojson") %>% st_transform(4326)
bb <- st_bbox(st_buffer(st_transform(ls_one, 27700), 250)) %>%
  st_as_sfc() %>% st_transform(4326) %>% st_bbox()
ggplot() +
  geom_sf(data = catch_sf, aes(fill = catchment), colour = "grey25", alpha = 0.10,
          linewidth = 0.9, show.legend = FALSE) +
  geom_sf(data = ls_one, fill = NA, colour = "black", linewidth = 0.9) +
  geom_sf(data = pp, aes(colour = Catchment), size = 2.6, alpha = 0.9) +
  scale_fill_manual(values = CATCH_COLOURS) +
  scale_colour_manual(
    values = setNames(unname(CATCH_COLOURS[as_boundary_catchment(unique(pp$catch_optionZ))]),
                      unname(CATCH_LABELS[as_boundary_catchment(unique(pp$catch_optionZ))])),
    name = "Catchment of the postcode") +
  coord_sf(xlim = bb[c("xmin", "xmax")], ylim = bb[c("ymin", "ymax")]) +
  labs(title = sprintf("%s, split between two catchments", pick),
       subtitle = sprintf("%d postcodes on one side, %d on the other",
                          max(table(pp$catch_optionZ)), min(table(pp$catch_optionZ)))) +
  theme_void(12) +
  theme(plot.title = element_text(face = "bold", colour = "#1f3b57"),
        plot.subtitle = element_text(colour = "grey35"), legend.position = "top")
Figure 1: One LSOA cut by the catchment boundary in force from 2026. Each point is a live postcode, coloured by the catchment it falls in; the black line is the LSOA boundary and the heavier line the catchment boundary. The model splits the LSOA’s children between the two sides in proportion to the postcodes on each.
Show code
z %>% filter(lsoa == pick) %>%
  transmute(Zone = zone, Postcodes = n_pcd,
            `Share of the LSOA` = sprintf("%.0f%%", 100 * n_pcd / sum(n_pcd)),
            `Age-11 share of the LSOA` = sprintf("%.4f", prop),
            `Weight used` = sprintf("%.4f", prop * n_pcd / sum(n_pcd)),
            `$O_i^{\\,2026}$` = sprintf("%.1f", Oi)) %>%
  knitr::kable(align = "lrrrrr")
The same LSOA, as the model sees it. The age-11 share belongs to the whole LSOA; each zone takes the part of it its postcodes imply.
Zone Postcodes Share of the LSOA Age-11 share of the LSOA Weight used \(O_i^{\,2026}\)
E01016952|DS_Varndean|DS_Varndean 67 60% 0.0008 0.0005 0.8
E01016952|Hove_Blatch|Hove_Blatch 45 40% 0.0008 0.0003 0.6

Before the correction both rows of that table carried the whole LSOA’s age-11 share, so the neighbourhood counted twice in its ward’s denominator and took roughly double the children it should.

2.5 A worked example

Show code
wd <- z %>% filter(ward == (z %>% arrange(desc(Oi)) %>% slice(1) %>% pull(ward)))
wd %>%
  arrange(desc(Oi)) %>%
  transmute(Zone = sub("[|]", " | ", zone), LSOA = lsoa, Postcodes = n_pcd,
            `Age-11 share of LSOA` = sprintf("%.4f", prop),
            `Share of the ward` = sprintf("%.3f", share_in_ward),
            `Ward children` = fmt_n(ward_children),
            `$O_i$ (2026)` = sprintf("%.1f", Oi)) %>%
  knitr::kable(align = "llrrrrr")
One ward, divided between its zones. Ward children is the ONS estimate for this cohort; the share is the zone’s weight within the ward; O is the two multiplied together with the state-sector share.
Zone LSOA Postcodes Age-11 share of LSOA Share of the ward Ward children \(O_i\) (2026)
E01016901 | DS_Varndean|DS_Varndean E01016901 18 0.0133 0.170 206 29.8
E01016902 | DS_Varndean|DS_Varndean E01016902 22 0.0089 0.113 206 19.9
E01016903 | DS_Varndean|DS_Varndean E01016903 22 0.0085 0.109 206 19.0
E01016939 | DS_Varndean|DS_Varndean E01016939 19 0.0084 0.107 206 18.7
E01016905 | DS_Varndean|DS_Varndean E01016905 28 0.0077 0.099 206 17.3
E01016940 | DS_Varndean|DS_Varndean E01016940 16 0.0072 0.092 206 16.2
E01016934 | DS_Varndean|DS_Varndean E01016934 15 0.0071 0.091 206 15.9
E01016932 | DS_Varndean|DS_Varndean E01016932 19 0.0061 0.078 206 13.6
E01016933 | DS_Varndean|DS_Varndean E01016933 16 0.0049 0.063 206 11.0
E01016900 | DS_Varndean|DS_Varndean E01016900 16 0.0078 0.044 206 7.8
E01016904 | DS_Varndean|DS_Varndean E01016904 16 0.0057 0.029 206 5.0
E01016900 | Patcham|Patcham E01016900 2 0.0078 0.006 206 1.0

Reading the first row: the ward holds 206 children of this cohort; that zone carries 17.0% of the ward’s weight; multiplied by the state-sector share of 0.850, the zone holds 29.8 Year 7 children in 2026.

2.6 The city total

Show code
zc %>% group_by(Catchment = catchment) %>%
  summarise(children = sum(Oi), Zones = n(), .groups = "drop") %>%
  arrange(desc(children)) %>%
  transmute(Catchment, `Children, 2026` = fmt_n(children), Zones) %>%
  knitr::kable(align = "lrr")
The modelled 2026 cohort by catchment. These are children living in the catchment, not children attending its school.
Catchment Children, 2026 Zones
Hove_Blatch 774 61
DS_Varndean 648 58
Longhill 264 29
PACA 230 15
Patcham 202 17
BACA 150 15

The city’s zones hold 2,267 children for 2026 entry. That is the model’s entire demand before any child chooses anything, and every intake the simulator reports is drawn from it.

2.6.1 Where the city totals come from

Two different city-wide totals appear in this work, and they have different provenance. It is worth being explicit about which is which.

Offers actually made are the council’s own published figures. Its allocation factsheets are issued after each March allocation round, and the series from 2010 to 2026 was transcribed school by school in the earlier Brighton and Hove allocations analysis. This bundle does not retype them: public/R/01a_factsheet_panel.R re-extracts the year-by-year tables from that document, so every offer, preference and admission number here traces back to a published council factsheet.

Resident children are ONS, as set out in Section 2.3. They are a different quantity from offers: a resident child may go to an independent school, or out of the city.

The model’s city total is the first anchored on the second. The state-sector share of 0.850 is the council’s published 2024 offer total divided by the ONS resident cohort for the same entry year, so the model’s demand is ONS children scaled to the level the council’s own offers imply. It is not a free-floating estimate.

Show code
fp <- bh_data("factsheet_panel.rds")$factsheets %>% filter(name != "Total")
off <- fp %>% group_by(year) %>% summarise(offers = sum(off_total), .groups = "drop")
tibble::tribble(
  ~Quantity, ~Source, ~Value,
  "Year 7 offers made, 2024", "Council allocation factsheet",
  fmt_n(off$offers[off$year == 2024]),
  "Year 7 offers made, latest round", "Council allocation factsheet",
  sprintf("%s (%d)", fmt_n(off$offers[which.max(off$year)]), max(off$year)),
  "Resident children, 2024 entry", "ONS ward estimates, mid-2022",
  fmt_n(dem_raw$children[dem_raw$area == "Brighton & Hove" & dem_raw$entry_year == 2024]),
  "State-sector share", "The first divided by the second",
  sprintf("%.3f", STATE_SHARE),
  "Model demand, 2026 entry", "ONS children x state share",
  fmt_n(sum(zc$Oi))) %>%
  knitr::kable(align = "llr")
The two totals, and the model’s demand between them.
Quantity Source Value
Year 7 offers made, 2024 Council allocation factsheet 2,229
Year 7 offers made, latest round Council allocation factsheet 2,250 (2026)
Resident children, 2024 entry ONS ward estimates, mid-2022 2,657
State-sector share The first divided by the second 0.850
Model demand, 2026 entry ONS children x state share 2,267

The model’s 2026 demand and the offers made in the 2026 round are not the same number and should not be expected to match exactly: the offers include children placed at a school they did not name, and exclude children who took a place outside the city. The comparison is still worth making, and it is the first check listed in Section 2.8.

2.7 The slider: how \(O_i\) changes with the entry year

The year slider does exactly one thing to \(O_i\). The city’s projected cohort is turned into an index on 2026, and every zone is scaled by it:

\[O_i^{\,y} \;=\; O_i^{\,2026} \times d^{\,y}, \qquad d^{\,y} \;=\; \frac{\text{state demand}^{\,y}}{\text{state demand}^{\,2026}}\]

Show code
dem %>%
  transmute(`Entry year` = year, `City cohort` = fmt_n(cohort),
            `Index on 2026` = sprintf("%.3f", index),
            Basis = ifelse(extrapolated, "extrapolated",
                           "children already counted")) %>%
  knitr::kable(align = "rrrl")
The demand index behind the slider. The last two years are the extrapolated cohorts.
Entry year City cohort Index on 2026 Basis
2026 2,267 1.000 children already counted
2027 2,164 0.955 children already counted
2028 2,070 0.913 children already counted
2029 2,041 0.901 children already counted
2030 1,975 0.871 children already counted
2031 1,982 0.874 children already counted
2032 1,823 0.804 children already counted
2033 1,916 0.845 children already counted
2034 1,875 0.827 extrapolated
2035 1,835 0.809 extrapolated
Show code
dem %>%
  ggplot(aes(year, cohort)) +
  geom_col(aes(fill = extrapolated), width = 0.66) +
  geom_text(aes(label = fmt_n(cohort)), vjust = -0.5, size = 3.2, colour = "grey25") +
  scale_fill_manual(values = c(`FALSE` = "#1f3b57", `TRUE` = "#9aa7b4"),
                    labels = c(`FALSE` = "children already counted",
                               `TRUE` = "extrapolated"), name = NULL) +
  scale_x_continuous(breaks = dem$year) +
  scale_y_continuous(labels = label_comma(), expand = expansion(mult = c(0, 0.12))) +
  labs(x = NULL, y = "State-sector Year 7 demand",
       title = "The cohort behind the slider, 2026 to 2035") +
  theme_bh() + theme(legend.position = "top")
Figure 2: The cohort the model scales to. Every zone is multiplied by the same factor, so the map of where children live is held still and only the size of the cohort moves.

2.7.1 Which projection this is, and the one beside it

Two projections of the city’s Year 7 cohort appear in this work. They are built from different data and they are used for different things, so it is worth being plain about which drives the model.

The one the slider uses is the ONS estimate carried forward: the children counted in the mid-2022 small-area estimates, aged forward to their Year 7 entry year (§2.4, steps 1 and 2), and multiplied by the state-sector share. It is used because it is the only one that exists at ward level, and the model needs the geography as well as the total — \(O_i\) is a ward figure divided between zones.

The one beside it is built from children already in the city’s primary schools: Reception offers made seven years earlier, multiplied by the share of a Reception cohort that reappears as a Year 7 offer. That ratio is measured rather than assumed, from the council’s own published factsheets, and it has been strikingly steady — 88.4% across the five most recent cohorts, with a standard deviation of 1.6%. The method is set out in full in how our projection method works, and section 3 of the strategic view uses it as its headline projection because it rests on children already registered in a school rather than on a population estimate.

Show code
rc$comparison %>%
  transmute(`Entry year` = entry_year,
            `From ONS estimates (drives the slider)` = fmt_n(ons_demand),
            `From Reception offers` = fmt_n(reception_based),
            Difference = sprintf("%+.0f", reception_based - ons_demand),
            `%` = sprintf("%+.1f%%", 100 * (reception_based / ons_demand - 1))) %>%
  knitr::kable(align = "rrrrr")
The two projections side by side. The first drives the model’s zone-level demand; the second is the check on it, and the strategic view’s headline series.
Entry year From ONS estimates (drives the slider) From Reception offers Difference %
2027 2,164 2,143 -21 -1.0%
2028 2,070 2,081 +11 +0.5%
2029 2,041 1,987 -55 -2.7%
2030 1,975 1,894 -81 -4.1%
2031 1,982 1,927 -55 -2.8%
2032 1,823 1,805 -18 -1.0%
2033 1,916 1,750 -165 -8.6%

They agree within a few per cent for most years, which is reassuring for both, and they diverge most in the years where the ONS series is itself extrapolated. Neither is a forecast in the sense of predicting behaviour: one counts children who exist, the other counts children already in school and applies a measured survival rate.

What this does not do. The projection is city-wide, so the slider cannot say that one neighbourhood shrinks faster than another: every zone keeps its 2026 share of the city’s children in every year. The council’s own forecasts differ from this mostly in exactly that respect — its Longhill catchment line falls faster than the city’s does — so comparing catchment totals year by year compares two different claims rather than two estimates of one thing.

2.8 Checks you can run

Show code
ward_tot <- z %>% group_by(ward) %>%
  summarise(oi = sum(Oi), implied = first(ward_children) * STATE_SHARE,
            .groups = "drop") %>%
  mutate(d = abs(oi - implied))
tibble::tribble(
  ~Check, ~Result,
  "Every zone has a positive cohort",
  sprintf("smallest is %.2f children", min(z$Oi)),
  "Zone cohorts sum to each ward's estimate times the state share",
  sprintf("largest difference %.3f children", max(ward_tot$d)),
  "The city's zones sum to the 2026 projection",
  sprintf("%s against %s", fmt_n(sum(zc$Oi)), fmt_n(dem$cohort[dem$year == 2026])),
  "The index is exactly 1 in the base year",
  sprintf("%.6f", dem$index[dem$year == 2026]),
  "A split LSOA takes the share its population implies",
  sprintf("largest ratio %.2f", max(chk$ratio))) %>%
  knitr::kable(align = "lr")
Identities that should hold, and what they give as this document is built.
Check Result
Every zone has a positive cohort smallest is 0.03 children
Zone cohorts sum to each ward’s estimate times the state share largest difference 0.000 children
The city’s zones sum to the 2026 projection 2,267 against 2,267
The index is exactly 1 in the base year 1.000000
A split LSOA takes the share its population implies largest ratio 1.53

Two further comparisons are worth doing by hand, because they test \(O_i\) against something the model never saw: the city total against the council’s published Year 7 offers for the same round, and each catchment’s total against the catchment forecasts in the council’s place planning appendix.

2.9 Checked against the council’s own count

Everything above tests \(O_i\) against its own ingredients. The council’s Educational Equity Framework makes an external test possible for the first time: its Appendix 1 counts Key Stage 2 and Key Stage 4 pupils by home ward, assigned from the January 2025 school census. The Key Stage 2 count is Year 6 in that census — the children who entered Year 7 the following September, which is the cohort this term is about. Its lineage shares nothing with ours: census home addresses, against our ONS mid-year estimates.

One feature of the table decides how it must be used. It counts pupils in Brighton & Hove schools. Residents at independent schools, at schools in other authorities, at non-maintained special schools, educated at home or solely registered at a pupil referral unit are all absent from it. So the right comparison is not against \(O_i\), which also holds the children the model sends to Priory, Peacehaven, Seahaven and Seaford Head, but against modelled flows into city schools by home ward. The council’s cohort is a year earlier and about 6% larger than the 2026 one, so what is compared is the shape: each ward’s modelled intake against its counted intake, after that city-wide difference is divided out.

Show code
wv$compare %>%
  mutate(ward = forcats::fct_reorder(ward, ratio)) %>%
  ggplot(aes(expected, modelled)) +
  geom_abline(slope = 1, intercept = 0, colour = "grey60", linetype = "31") +
  geom_point(colour = "#1f3b57", size = 2.6) +
  ggrepel::geom_text_repel(aes(label = ward), size = 3, colour = "grey25",
                           seed = 4, max.overlaps = 20, box.padding = 0.45) +
  scale_x_continuous(labels = label_comma()) +
  scale_y_continuous(labels = label_comma()) +
  labs(x = "Counted in city schools, January 2025 census (scaled to the 2026 cohort)",
       y = "Modelled intake into city schools, 2026",
       title = sprintf("The ward geography holds: correlation %.2f", wv_r)) +
  theme_bh()

Modelled intake into city schools against the council’s count, by home ward. A ward above the line is one the model sends to city schools in greater numbers than the census found there.

The geography is sound. What the residuals show is the assumption the previous page warned about: the single state-sector share. Rebuilding each ward’s children so that modelled city intakes match the counted ones — holding the city total fixed, so the council’s projection still sets the level — implies a share that is anything but flat.

Show code
wv$shares %>%
  transmute(Ward = ward,
            `ONS children` = fmt_n(ward_children),
            `Counted (KS2)` = fmt_n(ks2),
            `Implied share` = sprintf("%.2f", share),
            `Against the flat %.2f` = sprintf("%+.0f%%", 100 * (vs_flat - 1))) %>%
  rlang::set_names(c("Ward", "ONS children", "Counted (KS2)", "Implied share",
                     sprintf("Against the flat %.2f", wv$flat_share))) %>%
  knitr::kable(align = "lrrrr")
The share of each ward’s ONS children implied by the council’s count, against the flat share the model uses. Above 1.0 means the ONS estimate is too low for that ward, not that every child attends a city school.
Ward ONS children Counted (KS2) Implied share Against the flat 0.85
Round Hill 55 39 0.66 -22%
Wish 128 95 0.69 -18%
Rottingdean & West Saltdean 109 70 0.70 -18%
Brunswick & Adelaide 50 38 0.71 -16%
West Hill & North Laine 51 39 0.72 -16%
Hanover & Elm Grove 143 116 0.76 -11%
Westdene & Hove Park 203 170 0.78 -8%
Patcham & Hollingbury 182 153 0.79 -7%
Whitehawk & Marina 138 117 0.80 -6%
Westbourne & Poets’ Corner 108 94 0.81 -4%
Goldsmid 124 109 0.82 -3%
Preston Park 143 126 0.82 -3%
South Portslade 131 117 0.84 -2%
Regency 31 28 0.85 -1%
Hollingdean & Fiveways 206 188 0.85 +0%
Central Hove 86 79 0.86 +1%
Kemptown 40 37 0.87 +3%
North Portslade 136 129 0.89 +4%
Queen’s Park 80 77 0.90 +6%
Moulsecoomb & Bevendean 141 132 0.91 +8%
Hangleton & Knoll 215 237 1.03 +21%
Coldean & Stanmer 52 63 1.18 +39%
Woodingdean 114 108 1.21 +43%

It runs from 0.66 in Round Hill to 1.21 in Woodingdean. The low end is where independent-school take-up is heaviest and, in Rottingdean & West Saltdean, where children also cross the boundary eastwards. The values above one mean the ONS ward estimate is too low, so the model has been carrying too few children in Hangleton & Knoll and Coldean & Stanmer.

The test of whether this is a correction rather than a rearrangement is the published offers, which play no part in deriving the shares:

Show code
wv_fit %>%
  transmute(Origins = c("current" = "One share for the whole city",
                        "per_ward" = "A share for each ward")[origins],
            `R²` = sprintf("%.3f", r2),
            `Root mean square error` = sprintf("%.1f", rmse),
            `Mean absolute error` = sprintf("%.1f", mae)) %>%
  knitr::kable(align = "lrrr")
The same model, run on per-ward shares, against the offers Brighton & Hove schools made for September 2026.
Origins R² Root mean square error Mean absolute error
One share for the whole city 0.944 23.1 13.8
A share for each ward 0.959 19.8 11.3

The fit improves on every measure, and it improves at the schools where the model is furthest out: Patcham comes down from 216 to 203 against an observed 204, and Brighton Aldridge rises from 90 to 98, still short of its observed 146 but less so.

One caution about what the shares mean. For Woodingdean the counted intake is well above the modelled one, and the fix raises the ward’s children by about 40%. That is one of two things, and this table cannot tell them apart: either the ONS estimate is too low there, or the model sends too many Woodingdean children out of the city to Priory and Peacehaven. Both would show up the same way. The adjustment corrects the arithmetic either way, but only the first is a statement about how many children live there.

This is not yet in the model the simulator runs. The results in this guide, in the strategic view and in the simulator all use the flat share. Changing it rebuilds every number in every document, which is a decision to take deliberately rather than as a side effect of a validation section. What the section establishes is that the origins are sound in shape, that the flat share is the largest remaining error in them, and that the correction is available and improves the fit.

2.10 What to be careful about

  • The base is mid-2022. Migration into or out of the city since then is not in the counts, and a child who has moved between wards is still counted where they were.
  • One state-sector share, for every year and every neighbourhood. It is 0.850, from a single round. If independent-school take-up varies across the city — and it plainly does — then \(O_i\) is too high in some places and too low in others.
  • Ward-level age detail. Within a ward, the LSOA age-11 distribution does the work; how children are distributed inside an LSOA is not observed, so postcode counts stand in for it where a boundary cuts one.
  • Peacehaven and the expansion area are carried in the open bundle but excluded from the simulator’s zones. Peacehaven Community School is a destination in the model, not a source of demand.
  • No composition. \(O_i\) is a headcount. Which of those children are disadvantaged is a separate layer applied later, not part of this term.

2.11 Questions this raises

  1. Should the state-sector share vary by neighbourhood, given that independent-school take-up does?
  2. Should the year slider move the geography of demand as well as its size — that is, should each catchment carry its own projection, as the council’s forecasts do?

3 \(W_j\): how attractive each school is

3.1 What it is

\(W_j\) is the pull of school \(j\) on a family, net of where the school stands. It is dimensionless, scaled so the city average is 1, and it enters the model multiplicatively: doubling \(W_j\) doubles the numerator of every flow into \(j\) before distance, catchment and competition have their say. It also enters the competing-destinations term, because a school’s rivals count for more when they are schools families want.

It is the hardest term in the model to pin down, because everything observable about a school is entangled with where it is. A school with high attainment may be sought after because it teaches well, or because it sits where better-off families already live.

3.2 Two layers: the open specifications, and the fitted values

Layer one — the open specifications. The open bundle carries five candidate measures of attractiveness, so that a conclusion can be tested across all of them rather than resting on one.

Show code
oi$attract %>%
  transmute(School = short_sch(name), PAN = pan, `Attainment 8` = round(att8, 1),
            Equal = sprintf("%.2f", W_equal), `By PAN` = sprintf("%.2f", W_pan),
            `First preferences` = sprintf("%.2f", W_prefs),
            `Weighted preferences` = sprintf("%.2f", W_wprefs),
            `By Attainment 8` = sprintf("%.2f", W_att8)) %>%
  arrange(desc(as.numeric(`Weighted preferences`))) %>%
  knitr::kable(align = "lrrrrrrr")
The five open specifications, each scaled so the city average is 1. Weighted preferences count ranks 1 to 3, a second preference worth half a first and a third a quarter.
School PAN Attainment 8 Equal By PAN First preferences Weighted preferences By Attainment 8
King’s 165 56.9 1.00 0.66 1.71 1.79 2.43
Varndean 300 53.5 1.00 1.20 1.67 1.63 1.78
Cardinal Newman 360 53.1 1.00 1.45 1.53 1.28 1.72
Dorothy Stringer 330 53.5 1.00 1.32 0.94 1.23 1.77
Hove Park 180 45.2 1.00 0.72 0.81 1.21 0.82
Blatchington Mill 330 51.0 1.00 1.32 1.00 1.04 1.40
Patcham 225 47.8 1.00 0.90 1.01 0.95 1.04
Portslade Aldridge 220 45.9 1.00 0.88 0.85 0.73 0.87
Brighton Aldridge 180 36.8 1.00 0.72 0.66 0.52 0.37
Longhill 270 37.2 1.00 1.08 0.41 0.32 0.39
Peacehaven Community 180 40.0 1.00 0.72 0.41 0.32 0.51

The central one is weighted preferences per place: the council’s published factsheets, pooled over the most recent rounds, counting first, second and third preferences with a geometric discount of 0.5 per rank, divided by the admission number. The paired catchments are why: a family in Stringer / Varndean or Hove Park / Blatchington Mill must rank two local schools against each other, so a first-preference count reads a school families put second as one nobody wanted.

Layer two — the fitted values the simulator uses. The full model does not take any of those five as given. It balances \(W_j\) so that the model reproduces the share of the city’s first preferences each school actually receives, using the council’s catchment-level preference table from its evidence to the Schools Adjudicator. The catchment terms \(\gamma_h\) are fitted in the same loop (§6), because the two trade off against each other.

\[W_j \;\leftarrow\; W_j \left(\frac{\text{target share}_j}{\text{modelled share}_j}\right)^{0.8}, \qquad \text{repeat until } \max_j |\text{modelled} - \text{target}| < 10^{-6}\]

starting from the weighted-preference values, with the result rescaled to a city mean of 1. The exponent 0.8 is damping: it stops the balancing oscillating around the target. This is the same device as the balancing factor \(A_i\), applied to the other margin of the matrix — \(A_i\) makes every neighbourhood place all its children, and this makes every school receive the share of first preferences it is observed to receive.

Show code
tibble(name = names(W5), W5 = as.numeric(W5)) %>%
  left_join(oi$attract %>% select(name, W_wprefs, att8), by = "name") %>%
  left_join(tibble(name = names(cal$target_first_prefs),
                   target = as.numeric(cal$target_first_prefs)), by = "name") %>%
  arrange(desc(W5)) %>%
  transmute(School = short_sch(name),
            `Weighted preferences` = sprintf("%.2f", W_wprefs),
            `Fitted $W_j$` = sprintf("%.2f", W5),
            `Target share of first preferences` = sprintf("%.1f%%", 100 * target),
            `Attainment 8` = sprintf("%.1f", att8)) %>%
  knitr::kable(align = "lrrrr")
The fitted attractiveness the simulator’s sliders multiply, beside the weighted-preference measure it starts from, and each school’s target share of the city’s first preferences.
School Weighted preferences Fitted \(W_j\) Target share of first preferences Attainment 8
Cardinal Newman 1.28 3.27 19.6% 53.1
King’s 1.79 2.57 10.3% 56.9
Varndean 1.63 1.03 19.2% 53.5
Patcham 0.95 0.78 8.5% 47.8
Blatchington Mill 1.04 0.57 12.2% 51.0
Dorothy Stringer 1.23 0.55 10.5% 53.5
Brighton Aldridge 0.52 0.48 3.7% 36.8
Hove Park 1.21 0.36 5.7% 45.2
Portslade Aldridge 0.73 0.28 6.2% 45.9
Longhill 0.32 0.10 4.1% 37.2
Show code
tibble(name = names(W5), W5 = as.numeric(W5)) %>%
  left_join(oi$attract %>% select(name, W_wprefs), by = "name") %>%
  ggplot(aes(W_wprefs, W5)) +
  geom_abline(slope = 1, intercept = 0, colour = "grey60", linetype = "31") +
  geom_point(size = 3.4, colour = "#1f3b57") +
  ggrepel::geom_text_repel(aes(label = short_sch(name)), size = 3.4, seed = 1,
                           colour = "#1f3b57") +
  expand_limits(x = 0, y = 0) +
  labs(x = "Weighted preferences per place (city mean = 1)",
       y = "Fitted attractiveness (city mean = 1)",
       title = "What the balancing does to the starting values") +
  theme_bh()
Figure 3: The fitted attractiveness against the measure it starts from. A school above the line is one the model has to make more attractive than its weighted preferences suggest to reproduce the preferences each catchment gives it.

3.3 What point in time does \(W_j\) belong to?

\(W_j\) is fixed. One number per school, no year subscript, used for 2026 and for 2035 alike. That deserves stating plainly, because it is the assumption a reader is least likely to notice and the one that a school can falsify fastest.

Two windows go into it:

Show code
fp <- bh_data("factsheet_panel.rds")
tibble::tribble(
  ~Layer, ~Source, ~Window,
  "Starting values: weighted preferences per place",
  "Council allocation factsheets",
  sprintf("%d rounds, %d onwards", fp$pref_years, fp$attract_from),
  "Fitted values: balanced to first preferences",
  "Council's catchment-level preference table",
  sprintf("%d rounds, %s", length(cal$rounds), paste(cal$rounds, collapse = ", ")),
  "Rank discount inside the window",
  "A second preference counts this much of a first",
  sprintf("%.2f, and a third %.2f", fp$wpref_decay, fp$wpref_decay^2)) %>%
  knitr::kable(align = "lll")
The observations behind the attractiveness term.
Layer Source Window
Starting values: weighted preferences per place Council allocation factsheets 5 rounds, 2022 onwards
Fitted values: balanced to first preferences Council’s catchment-level preference table 3 rounds, 2023/24, 2024/25, 2025/26
Rank discount inside the window A second preference counts this much of a first 0.50, and a third 0.25

So the model’s attractiveness describes the city as families have treated it over the last three to five admissions rounds. It is a recent average, not a long-run constant, and not a snapshot of one year either — a single round moves too much to fit to.

Why that matters: the ordering is not stable over a decade.

Show code
fs <- fp$factsheets %>% filter(name != "Total") %>%
  mutate(School = short_sch(name),
         hl = School %in% c("King's", "Hove Park", "Longhill"))
ggplot(fs, aes(year, pref1, group = School)) +
  annotate("rect", xmin = fp$attract_from - 0.5, xmax = max(fs$year) + 0.4,
           ymin = -Inf, ymax = Inf, fill = "#1f3b57", alpha = 0.07) +
  geom_line(data = fs %>% filter(!hl), colour = "grey75", linewidth = 0.7) +
  geom_line(data = fs %>% filter(hl), aes(colour = School), linewidth = 1.3) +
  ggrepel::geom_text_repel(
    data = fs %>% filter(hl, year == max(year)), aes(label = School, colour = School),
    hjust = 0, nudge_x = 0.4, direction = "y", size = 4, seed = 2, show.legend = FALSE) +
  scale_colour_manual(values = c(`King's` = "#7570b3", `Hove Park` = "#d95f02",
                                Longhill = "#b2182b"), guide = "none") +
  scale_x_continuous(breaks = seq(2010, 2026, 4), limits = c(2010, 2029)) +
  expand_limits(y = 0) +
  labs(x = NULL, y = "First preferences",
       title = "How a school is regarded is not a constant",
       subtitle = sprintf("Published factsheets, %d to %d. Shaded: the rounds the attractiveness term uses.",
                          min(fs$year), max(fs$year))) +
  theme_bh()
Figure 4: First preferences for each school, from the published allocation factsheets. Nothing here is modelled. The shaded band is the window the attractiveness term is built from.

Over sixteen years King’s first preferences roughly doubled while Hove Park’s roughly halved, in the same part of the same city. A \(W_j\) built from 2014 would put those two schools the other way round from a \(W_j\) built from 2026. Reputations move, and they can move quickly: a new head, a turn in attendance or results, a building programme, an Ofsted judgement.

ImportantHow the model handles this, and what it cannot do

What it does. It fixes attractiveness at its recent value and gives you a slider to move it. Every scenario in the strategic view that says “a school half as attractive again” is that slider, and §3.6 converts a slider position into points of Attainment 8 so the size of the assumption is legible rather than abstract.

What it does not do. Attractiveness never responds to anything the model itself produces. A school whose roll falls does not become less wanted in the next year of the run; a school whose intake changes does not see its results, and therefore its reputation, move with it. The feedback loop the strategic view describes — a more disadvantaged intake, lower headline scores, fewer preferences, a more disadvantaged intake still — is outside this model. Where that loop is real, a run over ten years understates how far apart schools drift.

That is the clearest case in this document for making time explicit rather than implicit: \(O_i\) already carries a year, and the natural next step would be to let \(W_j\) carry one too.

3.4 Why there is no exponent on \(W_j\)

The general form of a production-constrained model carries a power on attractiveness as well as on cost:

\[T_{ij} \;=\; A_i\, O_i\, W_j^{\alpha}\, c_{ij}^{-\beta}\]

Here \(\alpha\) is absent, which is the same as fixing it at 1. That is a deliberate choice and it follows from what \(W_j\) is.

A fitted quantity does not need a parameter to scale it. \(W_j\) is not a proxy standing in for attractiveness; it is attractiveness, balanced until the model reproduces each school’s share of the city’s first preferences. An exponent on it would be a second free parameter doing the same job as the balancing, and the two would trade off against each other: any \(\alpha\) can be absorbed by re-balancing \(W\), because \((W^{1/\alpha})^{\alpha} = W\). Fitting both would be fitting the same thing twice, and the pair would not be identified.

It would be needed if \(W_j\) were a proxy. Suppose attractiveness were taken crudely as the number of pupils on roll, or as the admission number, or as Attainment 8 — all of which this work carries as alternative specifications (§3.2). None of those is pull; each is a measurable thing believed to move with it, and there is no reason the relationship should be one-for-one. A school twice the size is not necessarily twice as wanted. An exponent would then be exactly the right device: \(\alpha\) fitted to observed flows would say how strongly demand responds to the proxy, and \(\alpha = 1\) would be a finding rather than an assumption. In the classic retail application that is precisely what the exponent on floorspace does.

So the rule is: balance it, or scale it, but not both. This model balances. A version of it built on roll or on published attainment, which is what anyone without the council’s preference table would have to do, should carry an \(\alpha\) and fit it.

3.5 Where it sits in the matrix

Show code
data.frame(
  Neighbourhood = c(sub("[|].*$", "", ex_zones$zone), "$W_j$"),
  `Dorothy Stringer` = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[1]]])),
  Varndean = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[2]]])),
  Longhill = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[3]]])),
  `Cardinal Newman` = c(rep("$T_{ij}$", 4), sprintf("%.2f", W5[[sch4[4]]])),
  `$A_i$` = c(rep("?", 4), ""),
  `$O_i$ (2026)` = c(sprintf("%.1f", ex_zones$Oi), ""),
  check.names = FALSE) %>%
  knitr::kable(align = "lccccrr")
The same four neighbourhoods and schools. Two margins are now filled: the children in each neighbourhood, and the pull of each school.
Neighbourhood Dorothy Stringer Varndean Longhill Cardinal Newman \(A_i\) \(O_i\) (2026)
E01016901 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 29.8
E01016880 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 27.1
E01016979 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 25.5
E01016997 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) ? 24.3
\(W_j\) 0.55 1.03 0.10 3.27

3.6 What the attractiveness slider does

Each school’s slider multiplies its fitted \(W_j\), from 0.25 to 4 times. Nothing else changes: the same journey times, the same catchment terms, the same admission numbers. Three details are worth knowing.

  • The multiplier is applied to the fitted value, not to weighted preferences, so a slider at 1 reproduces the model exactly.
  • The out-of-city schools are not touched by it. Priory, Peacehaven, Seahaven and Seaford Head carry their own fitted attractiveness and a decay on straight-line distance, and the sliders do not reach them.
  • CoMArt has no preferences, because it does not exist. When it is opened, it borrows the attractiveness of Brighton Aldridge as its starting value, and its slider moves from there.

3.7 Turning a multiplier into something meaningful

A slider position is not a quantity anyone outside a model can judge, so the app converts it to the one published number families appear to respond to. Across the ten city schools, the fitted attractiveness is log-linear in Attainment 8:

\[\log W_j = a + b \cdot \text{Attainment 8}_j, \qquad b = 0.1123, \quad R^2 = 0.58, \quad n = 10\]

so a point of Attainment 8 is worth about 12% more attractiveness, and a multiplier \(m\) inverts to \(\log(m) / b\) points. That is how the simulator answers “how good would this school have to be to fill?” — and it is an association, not a lever: Attainment 8 is itself largely a description of who a school admits.

3.8 Checks you can run

Show code
own <- cal$own
tibble::tribble(
  ~Check, ~Result,
  "Fitted attractiveness averages 1 across the city",
  sprintf("%.6f", mean(W5)),
  "Balanced first-preference shares match the council's table",
  sprintf("largest gap %.4f percentage points",
          100 * max(abs(cal$cells$m5_share - cal$cells$obs_share))),
  "Share of each catchment's first preferences to its own school",
  sprintf("observed %.0f%% to %.0f%%, modelled %.0f%% to %.0f%%",
          100 * min(own$observed), 100 * max(own$observed),
          100 * min(own$M5), 100 * max(own$M5)),
  "Attainment 8 explains the fitted values",
  sprintf("R2 %.2f on %d schools", inp$attain$r2, inp$attain$n),
  "A slider at 1 reproduces the published model",
  "checked in app/tests/check.R") %>%
  knitr::kable(align = "lr")
Identities and fits that should hold for the attractiveness term.
Check Result
Fitted attractiveness averages 1 across the city 1.000000
Balanced first-preference shares match the council’s table largest gap 9.9965 percentage points
Share of each catchment’s first preferences to its own school observed 40% to 81%, modelled 32% to 83%
Attainment 8 explains the fitted values R2 0.58 on 10 schools
A slider at 1 reproduces the published model checked in app/tests/check.R

3.9 What to be careful about

  • Ten observations. Every statement about why a school is attractive rests on ten schools. The balancing is an accounting identity across those ten; the Attainment 8 fit is a description of them, not evidence about causes.
  • Attractiveness and location are only partly separated. The balancing asks what pull each school needs given where it stands and how far families will travel, which is a real separation — but it inherits whatever \(\beta\) and \(\gamma_h\) are set to. If distance decay were weaker, the fitted \(W_j\) would be different.
  • The faith schools have no catchment, so nothing in the model restricts who may choose them. Their fitted attractiveness absorbs the religious criteria that really ration those places, which is why they carry high values.
  • Peacehaven’s preferences are not published by the council, so both preference measures are filled at the lowest observed rate in the city. That is an assumption, and it flatters Longhill: a weak Peacehaven keeps eastern children in the city.
  • \(W_j\) does not move with time. A school that improves, or a new building, is a slider position rather than anything the model generates. Whether attractiveness should respond to a school’s roll, its intake or its results is exactly the dynamic question §2.7 flags.

3.10 Questions this raises

  1. Should \(W_j\) be allowed to vary by family type — by deprivation, say — rather than being one number per school for the whole city?
  2. Should the balancing target first preferences, as now, or all three ranks discounted as the open specification does?
  3. Should a school’s attractiveness respond to its own intake over time, which is the feedback loop the strategic view describes but the model does not contain?

4 \(c_{ij}\): the journey from a neighbourhood to a school

4.1 What it is

\(c_{ij}\) is the generalised cost of getting from neighbourhood \(i\) to school \(j\): minutes of walking and bus, door to door, on a weekday morning. It is not a straight-line distance, and it is not a car journey. It enters the model as \(c_{ij}^{-\beta}\), so it is the term that makes a school ten minutes away far more likely to be chosen than one an hour away — and the exponent \(\beta\) that governs how fast that falls away is its own section (§5).

The city has 2,925 zone-school pairs in the simulator’s cost table, spanning 3 to 473 minutes.

4.2 Where the numbers come from

Every journey is routed with r5r over two networks: the street network from OpenStreetMap, and the bus timetable as a GTFS feed from the Department for Transport’s Bus Open Data Service. The build is public/R/00b_build_travel_matrix.R in the open bundle.

Show code
tibble::tribble(
  ~Setting, ~Value,
  "Modes", paste(tm$params$mode, collapse = " and "),
  "Departure", format(tm$params$departure_datetime, "%A %d %B %Y, %H:%M"),
  "Longest walk allowed, either end", sprintf("%d minutes", tm$params$max_walk_time),
  "Longest journey allowed", sprintf("%d minutes", tm$params$max_trip_duration),
  "Departure window", sprintf("%d minute", tm$params$time_window),
  "Percentile of journeys in the window", sprintf("%dth", tm$params$percentiles),
  "Origins", sprintf("%s postcodes", fmt_n(tm$n_origins)),
  "Destinations", sprintf("%d, including the Elm Grove site", nrow(tm$destinations))) %>%
  knitr::kable(align = "lr")
The routing parameters. Every journey in this work uses these; changing any of them changes every journey time.
Setting Value
Modes WALK and TRANSIT
Departure NULL
Longest walk allowed, either end 60 minutes
Longest journey allowed 120 minutes
Departure window 1 minute
Percentile of journeys in the window 50th
Origins 7,896 postcodes
Destinations 15, including the Elm Grove site

The departure date is chosen rather than assumed: the build counts the trips each candidate weekday actually carries in the feed and takes a Tuesday, Wednesday or Thursday in service, because a date the feed does not serve returns no journeys at all and a Monday or Friday timetable is lighter than a normal school day.

4.3 From postcodes to zones

The matrix is routed postcode to school. A zone’s cost is the mean over the live postcodes in it:

\[c_{ij} \;=\; \frac{1}{|P_i|}\sum_{p \in P_i} c_{pj}\]

where \(P_i\) is the set of postcodes in zone \(i\). Because a zone is an LSOA cut by catchment (§2.2), the two halves of a split LSOA get their own journey times — which is the point of cutting them.

4.4 Two corrections the raw routing needs

Walking is always available. Where the bus network is awkward the router returns a poor itinerary: about a fifth of pairs came back slower than simply walking the distance, in one case 99 minutes for 4 km. That is a property of the router, not of the city, so every journey is capped at the time it would take to walk:

\[c_{ij} \;=\; \min\!\left(c^{\text{routed}}_{ij},\; \frac{d_{ij} \times 1.3}{4.35 \text{ km/h}} \times 60\right)\]

The walking pace comes from a systematic review of active school travel: children’s mean walking trip is 1,030 m in 14.2 minutes, or 4.35 km/h, and the circuity factor of 1.3 turns straight-line distance into street distance. The cap binds on 19% of pairs. It is a ceiling on modelled time, not a claim that children walk that far — the same review finds they rarely walk beyond 1.8 km.

A distance model fills the gaps. Where the matrix has no routed value at all, a fitted model of time on straight-line distance stands in. It reaches 0.1% of pairs in the city, so almost everything here is routed rather than predicted.

Show code
wk <- costs %>% mutate(walk_min = km * acc$walk_fallback$circuity / acc$walk_fallback$kmh * 60)
ggplot(wk, aes(km, cij)) +
  geom_point(aes(colour = walk_capped), size = 1.1, alpha = 0.5) +
  geom_line(aes(km, walk_min), colour = "#1f3b57", linewidth = 0.9) +
  scale_colour_manual(values = c(`FALSE` = "grey65", `TRUE` = "#d95f02"),
                      labels = c(`FALSE` = "routed time used",
                                 `TRUE` = "capped at walking time"), name = NULL) +
  labs(x = "Straight-line distance (km)", y = "Journey time used (minutes)",
       title = "Where the cap binds",
       subtitle = sprintf("%s of %s pairs are capped",
                          fmt_n(sum(costs$walk_capped)), fmt_n(nrow(costs)))) +
  theme_bh() + theme(legend.position = "top")
Figure 5: Every zone-school pair: routed journey time against straight-line distance. The line is the walking time the cap uses. Points above it are journeys the router made slower than walking, and they are pulled down onto the line.

4.5 What a journey-time surface looks like

Show code
lh_now <- to_lsoa(costs, LH) %>% mutate(site = "Ovingdean (today)")
lh_elm <- to_lsoa(cost_elm, LH) %>% mutate(site = "Elm Grove")
lh_map <- lsoa_sf %>%
  inner_join(bind_rows(lh_now, lh_elm), by = c("lsoa21cd" = "lsoa")) %>%
  mutate(site = factor(site, c("Ovingdean (today)", "Elm Grove")))
pts <- bind_rows(
  sch_sf %>% filter(name == LH) %>% mutate(site = "Ovingdean (today)"),
  st_as_sf(data.frame(e = oi$elm_grove$easting, n = oi$elm_grove$northing),
           coords = c("e", "n"), crs = 27700) %>% st_transform(4326) %>%
    mutate(site = "Elm Grove")) %>%
  mutate(site = factor(site, levels(lh_map$site)))
ggplot() +
  geom_sf(data = lh_map, aes(fill = cij), colour = NA) +
  geom_sf(data = catch_sf, fill = NA, colour = "white", linewidth = 0.25) +
  geom_sf(data = pts, shape = 21, fill = "white", colour = "black", size = 3, stroke = 1.1) +
  facet_wrap(~ site) +
  scale_fill_viridis_c(option = "magma", direction = -1, name = "Minutes") +
  labs(title = "The same school, two sites",
       subtitle = "Walk-and-bus journey time from each neighbourhood, weekday morning") +
  theme_void(12) +
  theme(plot.title = element_text(face = "bold", colour = "#1f3b57"),
        plot.subtitle = element_text(colour = "grey35"),
        strip.text = element_text(face = "bold"))
Figure 6: Journey time to Longhill from every neighbourhood, at its current site and at the Elm Grove site. Both panels use the same scale, so they can be read against each other. The white dot is the school.

The median neighbourhood is 67 minutes from Ovingdean and 44 from Elm Grove. That difference is the whole of what relocation does to \(c_{ij}\) — and, because nothing else in the model moves with it except the competition term, most of what relocation does at all.

Show code
nearest <- costs %>%
  inner_join(zone_lsoa, by = "zone") %>%
  group_by(lsoa, name) %>% summarise(cij = weighted.mean(cij, Oi), .groups = "drop_last") %>%
  slice_min(cij, n = 1, with_ties = FALSE) %>% ungroup() %>%
  mutate(School = short_sch(name))
near_map <- lsoa_sf %>% inner_join(nearest, by = c("lsoa21cd" = "lsoa"))
ggplot() +
  geom_sf(data = near_map, aes(fill = School), colour = "white", linewidth = 0.1) +
  geom_sf(data = catch_sf, fill = NA, colour = "grey15", linewidth = 0.6) +
  geom_sf(data = sch_sf, shape = 21, fill = "white", colour = "black", size = 2.6) +
  scale_fill_brewer(palette = "Set3", name = NULL) +
  labs(title = "The quickest school from each neighbourhood",
       subtitle = "Routed walk-and-bus time, not distance, and not who is admitted") +
  theme_void(12) +
  theme(plot.title = element_text(face = "bold", colour = "#1f3b57"),
        plot.subtitle = element_text(colour = "grey35"))
Figure 7: Which school each neighbourhood can reach quickest, by routed walk-and-bus time. Boundaries are the catchments in force; where a colour crosses a boundary, the catchment school is not the nearest one.

4.6 A worked example

Show code
ex_zone <- costs %>% filter(zone == ex_zones$zone[1])
ex_zone %>%
  arrange(cij) %>%
  transmute(School = short_sch(name), `Journey time (minutes)` = sprintf("%.0f", cij),
            `Straight line (km)` = sprintf("%.1f", km),
            Basis = ifelse(walk_capped, "capped at walking time",
                           ifelse(routed, "routed", "distance model"))) %>%
  knitr::kable(align = "lrrl")
One neighbourhood’s journey to every city school, with the straight-line distance beside it. The two are not the same ordering, which is the reason for routing at all.
School Journey time (minutes) Straight line (km) Basis
Varndean 13 0.7 capped at walking time
Dorothy Stringer 17 1.0 capped at walking time
Patcham 35 2.1 routed
Cardinal Newman 37 2.1 capped at walking time
Brighton Aldridge 37 3.0 routed
Hove Park 58 3.4 routed
Blatchington Mill 62 3.5 routed
Portslade Aldridge 64 6.7 routed
Longhill 67 5.3 routed
Peacehaven Community 68 10.9 routed
King’s 71 4.4 routed

4.6.1 The same neighbourhood, routed

The table gives one number per school. Behind each number is a journey, and it is worth seeing what the journey involves. The map routes the same neighbourhood to every city school, leg by leg, on the same network and the same weekday-morning timetable the model uses.

Show code
gr_bb <- sf::st_bbox(gr_legs)
sch_pts <- schools_sf(oi$schools) %>%
  filter(name %in% gr_sum$school)

mr <- leaflet(width = "100%", height = 620) %>%
  add_basemap() %>%
  addPolygons(data = catch_sf, fill = FALSE, color = "#555555",
              weight = 1, opacity = 0.5, group = "Catchment boundaries")

for (i in seq_len(nrow(gr_sum))) {
  s <- gr_sum$school[i]; g <- gr_sum$label[i]
  d <- gr_legs %>% filter(school == s)
  b <- gr_bus %>% filter(school == s)
  leg_label <- function(x) sprintf("Leg %d: %s%s, %.0f min", x$leg, x$mode_lab,
                                   if_else(nzchar(x$route), paste0(" ", x$route), ""),
                                   x$minutes + x$wait)
  dw <- d %>% filter(mode_lab == "Walk")
  db <- d %>% filter(mode_lab == "Bus")
  if (nrow(dw))
    mr <- mr %>% addPolylines(data = dw, group = g, color = GR_COL[["Walk"]],
                              weight = 5, opacity = 0.9, dashArray = "6 6",
                              label = leg_label(dw))
  if (nrow(db))
    mr <- mr %>% addPolylines(data = db, group = g, color = GR_COL[["Bus"]],
                              weight = 5, opacity = 0.85, label = leg_label(db))
  mr <- mr %>%
    addCircleMarkers(data = sch_pts %>% filter(name == s), group = g,
                     radius = 7, color = "#111111", weight = 2, fillColor = "#ffffff",
                     fillOpacity = 1, label = ~ name) %>%
    addCircleMarkers(data = sch_pts %>% filter(name != s), group = g,
                     radius = 3, color = "#777777", weight = 1, fillColor = "#cccccc",
                     fillOpacity = 0.9, label = ~ name) %>%
    addCircleMarkers(lng = gr$origin$lon, lat = gr$origin$lat, group = g,
                     radius = 7, color = "#111111", weight = 2, fillColor = "#b2182b",
                     fillOpacity = 1, label = "The worked neighbourhood")
  if (nrow(b))
    mr <- mr %>%
      addLabelOnlyMarkers(lng = b$lon, lat = b$lat, group = g,
                          label = paste("Bus", b$route),
                          labelOptions = labelOptions(noHide = TRUE, direction = "top",
                                                      textOnly = FALSE,
                                                      style = list("font-weight" = "bold",
                                                                   "color" = "#2166ac")))
}

mr %>%
  fitBounds(lng1 = unname(gr_bb["xmin"]) - 0.005, lat1 = unname(gr_bb["ymin"]) - 0.004,
            lng2 = unname(gr_bb["xmax"]) + 0.005, lat2 = unname(gr_bb["ymax"]) + 0.004) %>%
  addLayersControl(baseGroups = gr_sum$label,
                   overlayGroups = "Catchment boundaries",
                   options = layersControlOptions(collapsed = FALSE)) %>%
  hideGroup("Catchment boundaries") %>%
  addLegend("bottomright", colors = unname(GR_COL), labels = names(GR_COL),
            title = "Leg", opacity = 0.9) %>%
  # Zoom to the chosen school's route, on load and whenever it changes.
  htmlwidgets::onRender("
    function(el, x) {
      var map = this;
      function fitRoute(group) {
        var b = L.latLngBounds([]);
        group.eachLayer(function(l) {
          if (l instanceof L.Polyline) b.extend(l.getBounds());
        });
        if (b.isValid()) map.fitBounds(b.pad(0.25), {maxZoom: 16});
      }
      map.on('baselayerchange', function(e) { fitRoute(e.layer); });
      setTimeout(function() {
        var active = el.querySelector('.leaflet-control-layers-base input:checked');
        if (!active || !active.nextSibling || !map.layerManager) return;
        var grp = map.layerManager.getLayerGroup(active.nextSibling.textContent.trim(), false);
        if (grp) fitRoute(grp);
      }, 300);
    }")
Figure 8: Indicative routes from the worked neighbourhood to each city school. Choose a school on the right. Walking legs are orange and follow the street network; bus legs are blue, follow the service’s own route and are labelled with the service number. Schools the model caps at walking time are routed on foot only. The route starts at the neighbourhood’s centre, so its time differs a little from the model’s, which averages over every postcode in the neighbourhood. Both ends are snapped to the nearest street, so a line can stop just short of its marker.
Show code
gr_sum %>%
  transmute(School = label,
            `Model (minutes)` = sprintf("%.0f", model_minutes),
            `This route (minutes)` = route_minutes,
            Legs = legs,
            `Buses, in order` = if_else(nzchar(services), gsub(" then ", " → ", services), "walk only"),
            Changes = changes,
            Basis = if_else(walk_capped, "walking", "walk and bus")) %>%
  knitr::kable(align = "lrrrlrl")
The routes on the map, against the model’s journey time for the neighbourhood.
School Model (minutes) This route (minutes) Legs Buses, in order Changes Basis
Varndean 13 17 1 walk only 0 walking
Brighton Aldridge 37 35 5 50 → 28 1 walk and bus
Hove Park 58 52 5 50 → 49 → 5 2 walk and bus
King’s 71 63 5 50 → 49 → 5 2 walk and bus
Dorothy Stringer 17 23 1 walk only 0 walking
Patcham 35 28 5 26 → 5B 1 walk and bus
Cardinal Newman 37 45 1 walk only 0 walking
Blatchington Mill 62 58 4 46 → 5B 1 walk and bus
Portslade Aldridge 64 59 4 46 → 1X 1 walk and bus
Longhill 67 57 7 46 → 27 → 2 2 walk and bus

Four of them show the range:

  • Varndean is a walk of about 17 minutes. The model caps it at walking time, because no bus journey beats walking there.
  • Brighton Aldridge is 35 minutes by bus (50, then 28), with 1 change.
  • Hove Park is 52 minutes, with 2 changes, for a school 3.4 km away in a straight line.
  • King’s is 63 minutes, with 2 changes, on the same buses as Hove Park, carried further west.

The ordering by time is not the ordering by distance, which is the reason for routing at all. King’s is 4.4 km away and takes 63 minutes; Portslade Aldridge is 6.7 km away and takes 59, because it needs one change fewer.

4.7 The journeys the model does not route

  • The four schools outside the city — Priory, Peacehaven, Seahaven and Seaford Head — are chosen on straight-line kilometres, not routed minutes. The bus network has no direct eastern service to Lewes, so the routed journey from Woodingdean goes through Brighton and takes over an hour, which is not what the families who make that journey do. Their decay is fitted on km against the published 2024 destinations.
  • CoMArt, when opened, is routed from its old site on the same network and with the same parameters as every other school.
  • Longhill at Elm Grove has a routed column of its own. An earlier version had no routed times for the site and gave it the average accessibility of a Brighton secondary, which was wrong by about sixty children; the build now stops rather than let that happen silently.

4.8 Corrected: Hove Park has two campuses

Every school in the cost table sits where the school register puts it, which is the address the school gives. For Hove Park that address is the Nevill Campus on Nevill Road. But Hove Park teaches Years 7 and 8 at the Valley Campus on Hangleton Way, 1.65 km further west, and only moves pupils to Nevill Road in Year 9. This model is about Year 7. Every journey time it used for Hove Park was therefore a journey to a building the modelled children would not attend for two years.

The error is not random. The Valley Campus is west of the Nevill Campus, towards Hangleton and Portslade and away from central Hove and Brighton, so putting Year 7 at Nevill Road makes the school look nearer than it is to the parts of the city with most children in them, and further than it is from Hangleton. Routed to both sites, the Valley Campus is a median +9.2 minutes from the city’s neighbourhoods, but quicker from the 14% of them that lie west and north west.

This is temporary. The council gave statutory notice on 8 June 2026 to discontinue the Valley Campus on 31 August 2028 and consolidate the school on Nevill Road from 1 September 2028. So the model now carries both sites: entry years up to 2027 are taught at Hangleton Way, 2028 onwards at Nevill Road, and run_sim() picks the campus from the entry year unless told otherwise. Everything fitted to preferences or offers — attractiveness, the catchment terms, the competing-destinations term — is fitted with Year 7 at the Valley Campus, because every round it is fitted to was taught there.

Show code
tibble::tibble(
  Measure = c("Hove Park's intake",
              "Its mean journey",
              "Blatchington Mill's intake",
              "Portslade Aldridge's intake"),
  `Valley Campus` = c(
    sprintf("%.0f", hp_int(hp_v)),
    sprintf("%.1f min", hp_v$schools$mean_min[hp_v$schools$name == HPN]),
    sprintf("%.0f", hp_int(hp_v, "Blatchington Mill School")),
    sprintf("%.0f", hp_int(hp_v, "Portslade Aldridge Community Academy"))),
  `Nevill Road` = c(
    sprintf("%.0f", hp_int(hp_n)),
    sprintf("%.1f min", hp_n$schools$mean_min[hp_n$schools$name == HPN]),
    sprintf("%.0f", hp_int(hp_n, "Blatchington Mill School")),
    sprintf("%.0f", hp_int(hp_n, "Portslade Aldridge Community Academy")))) %>%
  knitr::kable(align = "lrr")
The same entry year, run at each campus. Everything else is held: the same attractiveness, the same catchments, the same admission numbers.
Measure Valley Campus Nevill Road
Hove Park’s intake 168 180
Its mean journey 27.7 min 27.0 min
Blatchington Mill’s intake 330 328
Portslade Aldridge’s intake 153 154

The totals barely move: 12 children on an intake of about 168. That is the answer to the obvious question, and it is worth stating plainly, because it means the campus was not the reason the model has trouble with Hove Park.

What does change, and changes a great deal, is which children:

Show code
hp_comp %>%
  slice_head(n = 6) %>%
  transmute(Ward = ward,
            `At the Valley Campus` = sprintf("%.0f", valley),
            `At Nevill Road` = sprintf("%.0f", nevill),
            Difference = sprintf("%+.0f", change)) %>%
  knitr::kable(align = "lrrr")
Hove Park’s modelled Year 7, by home ward, at each campus. The six wards the campus matters most for.
Ward At the Valley Campus At Nevill Road Difference
Hangleton & Knoll 51 26 +25
Westdene & Hove Park 10 33 -23
Westbourne & Poets’ Corner 13 25 -12
South Portslade 19 11 +9
Central Hove 9 15 -6
Goldsmid 8 13 -6

About a quarter of the school’s intake changes ward. At Hangleton Way it draws heavily on Hangleton & Knoll, the ward it actually sits in, and on Portslade; at Nevill Road it draws instead on Westdene & Hove Park and Westbourne & Poets’ Corner. Those are different children, with different journeys and a different level of disadvantage, so every measure built on the composition of an intake — the segregation index, deprived children’s journey times, each school’s free-school-meal share — was being computed for the wrong families in the city’s largest paired catchment.

NoteWhat this does and does not fix

It does not fix Hove Park’s intake. The model still puts more children there than the school was offered in September 2026, and the reason lies elsewhere: most of Hove Park’s modelled intake is overflow refused at Blatchington Mill, and the city’s take-up of state places is not uniform (Section 2.9). Correcting the campus moves those children between wards rather than out of the school.

What it fixes is the geography, which everything downstream is built on. It also repaired a second fault it exposed: the out-of-city fit is a likelihood with more than one basin, and moving Hove Park was enough to send the optimiser into the wrong one, which cut the modelled flow from Longhill’s catchment to Priory School from about 35 children to almost none, at a likelihood six times worse. The starting grid is wider now and the fitted likelihood is reported, so the next time the geometry moves this shows up rather than passing silently.

Two things say the recovered basin is the right one rather than merely the better-fitting one. It reproduces the council’s published 2024 destinations — 38 children from Longhill’s catchment to Priory, against 35 modelled. And it puts them where local knowledge puts them: of the 46 children the model sends to Priory, 57% are from Woodingdean, the part of the city closest to Lewes, and the next largest share is from Rottingdean & West Saltdean. That outflow is a real feature of the eastern edge of the city and has no counterpart in the west, which is exactly why a badly-fitted version of it is easy to miss: nothing on the Hove side of the city would look wrong.

4.9 Checks you can run

Show code
st <- acc$statutory
tibble::tribble(
  ~Check, ~Result,
  "Every zone can reach every city school",
  sprintf("%s pairs, none missing", fmt_n(nrow(inp$cost$now))),
  "Journeys are routed rather than predicted",
  sprintf("%.1f%% routed", 100 * mean(costs$routed)),
  "No journey is slower than walking",
  sprintf("%.0f%% capped, none above the walk line", 100 * mean(costs$walk_capped)),
  "Mean journey to the catchment school",
  sprintf("%.1f minutes", st$mean_min),
  "Against the statutory limit for secondary (75 minutes)",
  sprintf("%.0f children over it", st$over_75_children),
  "Against the national average school journey (19 minutes)",
  sprintf("city mean %.1f minutes", st$mean_min)) %>%
  knitr::kable(align = "lr")
What the cost table should satisfy, and what it gives.
Check Result
Every zone can reach every city school 2,925 pairs, none missing
Journeys are routed rather than predicted 99.9% routed
No journey is slower than walking 19% capped, none above the walk line
Mean journey to the catchment school 22.9 minutes
Against the statutory limit for secondary (75 minutes) 0 children over it
Against the national average school journey (19 minutes) city mean 22.9 minutes

The strongest check is not in the table: a handful of journeys can be compared against local knowledge. Woodingdean to Longhill comes out at about 17 minutes and Woodingdean to Dorothy Stringer at about 68; Whitehawk to Longhill about 28 and to Stringer about 62. Those are the journeys that decide the eastern half of this work, and they are worth checking against what a family there would say.

4.10 What to be careful about

  • One departure, one day. Every journey is a single weekday morning in the timetable as it stood when the matrix was built. Term-time variation, a missed connection and a cancelled bus are all outside it.
  • A mean over postcodes. A zone’s cost is the average of the postcodes in it, so a neighbourhood with one badly connected corner shows as slightly worse rather than showing both.
  • No cars, no lifts, no walking buses. The model prices the journey a child could make on foot and by bus. A family with a car makes a different journey, and that is one reason the east’s figures should be read as a ceiling on difficulty rather than a description of it.
  • Time is symmetric here. One morning journey stands for the return as well, and no allowance is made for after-school travel.
  • The cap is generous at distance. A 6 km walk is not a real school journey, but capping at walking time is deliberately crude: it removes router artefacts without inventing a bus that does not run.

4.11 Questions this raises

  1. Should the zone cost be the mean over its postcodes, or the child-weighted mean, or the best available journey rather than the average one?
  2. Should journeys be routed at more than one departure time, so that a thin early timetable is visible?
  3. Should the out-of-city schools be routed rather than measured on straight-line distance, if a feed covering East Sussex services were used?

5 \(\beta\): how sharply distance deters

5.1 What it is

\(\beta\) is the exponent on journey time. It converts the minutes of §4 into a pull:

\[\text{pull of school } j \text{ on neighbourhood } i \;\propto\; c_{ij}^{-\beta}\]

A larger \(\beta\) means families discount distance more sharply. It is a single number for the whole city, fixed at 1.7, and it is the same in every run of the simulator: no slider moves it.

The form matters as much as the value. This is a power decay, not an exponential one: doubling the journey divides the pull by \(2^{1.7} \approx 3.2\), whatever the starting point. That is the standard form in spatial interaction modelling, and it behaves sensibly over the range of journeys a city this size contains.

Show code
bs <- c(1.5, BETA, 2.4, 3.2)
tidyr::expand_grid(beta = bs, mins = seq(10, 70, by = 0.5)) %>%
  mutate(pull = (mins^-beta) / (10^-beta),
         lab = sprintf("β = %.1f", beta),
         model = beta == BETA) %>%
  ggplot(aes(mins, pull, colour = lab, linewidth = model)) +
  geom_line() +
  scale_linewidth_manual(values = c(`FALSE` = 0.7, `TRUE` = 1.6), guide = "none") +
  scale_colour_manual(values = c("grey70", "#1f3b57", "grey55", "grey40"), name = NULL) +
  scale_y_continuous(labels = label_percent()) +
  labs(x = "Journey time (minutes)", y = "Pull, relative to a ten-minute journey",
       title = "A school twice as far away is a fraction as likely to be chosen",
       subtitle = sprintf("At β = %.1f, a 20-minute journey has %.0f%% of the pull of a 10-minute one, and 40 minutes has %.0f%%",
                          BETA, 100 * 2^-BETA, 100 * 4^-BETA)) +
  theme_bh() + theme(legend.position = "top")
Figure 9: What the exponent does. Each curve is the pull of a school against journey time, scaled to 1 at ten minutes. The model uses 1.7; the sweep in the open model runs from 1.5 to 3.2.

5.2 Where the value comes from, and what it is not

It is a judgement, not an estimate. Fitting \(\beta\) properly needs observed flows — how many children actually travel from each neighbourhood to each school — and the council has not released them. A catchment-level preference table identifies the catchment terms (§6, to follow) but not distance decay, because within a catchment every family is at a different distance and the table does not say who is where.

So 1.7 is a central value chosen for a system of this kind: journeys are short, the alternatives sit close together, and it is squarely inside the range this literature reports for school choice. The open model does not rely on it being right. It runs everything across 18 values from 1.5 to 3.2 and reports which conclusions survive the whole range.

NoteWhat would pin it down

A Poisson regression of flows on log journey time with an origin fixed effect returns \(\beta\) with a standard error, from records the admissions process already produces. It is half a day’s work on data the council holds. Every band in the open model that comes from sweeping \(\beta\) would become a number with an interval around it.

5.3 How much rests on it

Show code
env$importance %>%
  filter(term != "Residuals") %>%
  mutate(Input = recode(term,
    "w_spec" = "Which attractiveness specification",
    "factor(entry_year)" = "Which entry year",
    "site_label" = "Longhill's site",
    "factor(beta)" = "Distance decay (β)",
    "factor(gamma)" = "Strength of the catchment term (γ)")) %>%
  arrange(desc(pct)) %>%
  transmute(Input, `Share of the variation` = sprintf("%.1f%%", pct)) %>%
  knitr::kable(align = "lr")
What the open model’s answer is actually sensitive to. Share of the variation in Longhill’s modelled recruitment across the full sweep, by input.
Input Share of the variation
Which attractiveness specification 45.0%
Which entry year 21.4%
Longhill’s site 15.5%
Distance decay (β) 7.1%
Strength of the catchment term (γ) 2.8%

Distance decay is not the big unknown. It accounts for 7% of the variation in the answer, against 45% for how attractive each school is taken to be. That is worth knowing because it says what data would be worth having: school-level revealed preference, more than travel behaviour.

Show code
env$envelope %>%
  filter(entry_year == 2026, gamma == 1.6) %>%
  mutate(site = ifelse(site == "now", "Ovingdean (today)", "Elm Grove")) %>%
  ggplot(aes(beta, natural_intake, colour = w_label, group = w_label)) +
  geom_vline(xintercept = BETA, colour = "grey45", linetype = "31") +
  geom_line(linewidth = 0.9) +
  facet_wrap(~ site) +
  scale_colour_brewer(palette = "Dark2", name = NULL) +
  expand_limits(y = 0) +
  labs(x = "Distance decay (β)", y = "Children recruited",
       title = "The slope along each line is what β decides",
       subtitle = "Longhill's recruitment with its own admission number unbinding, catchment term at 1.6") +
  theme_bh() + theme(legend.position = "top")
Figure 10: Longhill’s modelled recruitment across the swept range of distance decay, at each attractiveness specification, 2026. The vertical line is the value the model uses. The spread between the coloured lines is the attractiveness question; the slope along each line is the distance-decay question.

5.4 What it looks like from one neighbourhood

Show code
zex <- costs %>% filter(zone == ex_zones$zone[1])
tidyr::expand_grid(beta = c(1.5, BETA, 3.2), zex) %>%
  group_by(beta) %>%
  mutate(share = cij^-beta / sum(cij^-beta),
         School = short_sch(name)) %>%
  ungroup() %>%
  mutate(lab = sprintf("β = %.1f", beta),
         School = forcats::fct_reorder(School, cij)) %>%
  ggplot(aes(share, School, fill = lab)) +
  geom_col(position = position_dodge(width = 0.75), width = 0.7) +
  scale_x_continuous(labels = label_percent()) +
  scale_fill_manual(values = c("grey72", "#1f3b57", "grey45"), name = NULL) +
  labs(x = "Share of the neighbourhood's children", y = NULL,
       title = sprintf("Distance alone, from %s", sub("[|].*$", "", ex_zones$zone[1])),
       subtitle = "Schools ordered by journey time. A steeper decay concentrates children on the nearest schools.") +
  theme_bh() + theme(legend.position = "top")
Figure 11: One neighbourhood’s children, shared between schools by journey time alone, at three values of the exponent. Attractiveness, catchment and capacity are all switched off here, so the only thing acting is distance.

That is the whole of what the exponent does: at 1.5 the nearest school takes 35% of this neighbourhood’s children on distance alone; at 3.2, 65%.

5.5 A second decay, on a different scale

The four schools outside the city do not use \(\beta\) at all. They are chosen on straight-line kilometres with a decay of their own, fitted to the published 2024 destinations:

\[\text{pull of an out-of-city school} \;=\; W^{\text{ext}}_j \; d_{ij}^{-8.0}\]

8.0 is far steeper than 1.7, and the two are not comparable: this one is on kilometres rather than minutes, and it is doing a different job. It is absorbing everything that stops a Brighton family choosing a school in another authority — the admissions arrangements of a different council, the absence of a school bus, the sheer unfamiliarity — and compressing it into the one dimension the fit has. It reproduces the published pattern, which is a low bar for four schools and one year of data, and it should not be read as a behavioural estimate at all.

5.6 Checks you can run

Show code
r_now <- run_sim(inp, year = 2026, rules = list(rule = "priorities"))
m_now <- outcomes(inp, r_now)
tibble::tribble(
  ~Check, ~Result,
  "The exponent the model runs at",
  sprintf("%.1f, unchanged in every scenario", BETA),
  "Mean modelled journey to the school a child attends",
  sprintf("%.1f minutes", m_now$mean_min),
  "Against the national average school journey",
  sprintf("%.0f minutes (National Travel Survey)", acc$benchmarks$nts_min),
  "Share of children modelled at a school in their own catchment",
  sprintf("%.0f%%", 100 * r_now$in_catch_share),
  "Sensitivity of the answer to the exponent",
  sprintf("%.0f%% of the variation in the sweep", env$importance$pct[env$importance$term == "factor(beta)"])) %>%
  knitr::kable(align = "lr")
What the decay should produce, and what it gives at the value the model uses.
Check Result
The exponent the model runs at 1.7, unchanged in every scenario
Mean modelled journey to the school a child attends 29.5 minutes
Against the national average school journey 19 minutes (National Travel Survey)
Share of children modelled at a school in their own catchment 58%
Sensitivity of the answer to the exponent 7% of the variation in the sweep

The mean modelled journey sits above the national average, which is what a city with a coastal edge and one school in the far east should produce. If it came out at or below the national figure, either the decay or the journey times would be wrong.

5.7 What to be careful about

  • One exponent for the whole city. Families differ in how far they will travel, and the literature is consistent that the willingness varies with income and with whether a car is available. A single \(\beta\) averages over that, and it is the assumption most likely to matter for the equity questions in the strategic view.
  • Everything else is fitted given this value. The attractiveness balancing (§3) and the catchment terms (§6) are estimated with \(\beta\) held at 1.7. A different decay would give different fitted values for both, which is why the sweep re-runs the whole model rather than swapping the exponent at the end.
  • Time, not distance, and not cost. A journey that is quick but requires two buses and a fare is treated as a short journey.
  • The floor at short journeys. A power decay rises steeply as time approaches zero, so the very shortest journeys carry a lot of weight. The routed minimum in the table is 3 minutes, which keeps it in bounds.

5.8 Questions this raises

  1. Should \(\beta\) vary between groups of families — by deprivation, or by whether a catchment has one school or two — rather than being one number for the city?
  2. Is the power form right, or would an exponential decay fit the pattern of Brighton’s journeys better? Both are standard; they differ most at the long-journey end, which is where the eastern questions sit.
  3. Should the out-of-city decay be re-fitted once more than one year of destination data is available, or replaced by a proper choice model for schools in another authority?

6 \(\gamma_h\): how much living in the catchment counts

6.1 What it is

\(\gamma_h\) is the bonus a school gets for being in the family’s own catchment. It sits in the exponent, so it multiplies a school’s pull:

\[\text{pull} \;\propto\; W_j\, c_{ij}^{-\beta} \times \begin{cases} e^{\gamma_{h(i)}} & \text{if } j \text{ is a school of } i\text{'s catchment}\\ 1 & \text{otherwise}\end{cases}\]

The subscript matters: \(\gamma\) is indexed by \(h(i)\), the catchment the family lives in, not by the school. Stringer / Varndean families are modelled as following their map more closely than Brighton Aldridge’s families follow theirs, and that is a statement about families rather than about schools.

ImportantThis is about what families name, not about who gets admitted

Admission priority is a separate stage of the model (§9): when a school is oversubscribed the council’s criteria decide who is offered a place, and living in the catchment is one of them. \(\gamma_h\) is doing something different and earlier. It says how much more likely a family is to want a school because it is the one their map points them to — through habit, siblings, the school bus, or simply reading the catchment as a statement about where their child belongs.

The distinction is why the strategic view can find that catchment priority allocates nothing at an undersubscribed school while the catchment still matters a great deal there: nobody is turned away, but the map still shapes what families ask for.

6.2 The fitted values

Show code
tibble(catchment = names(gam), gamma = as.numeric(gam)) %>%
  mutate(Catchment = unname(gam_lab[catchment]),
         `Fitted $\\gamma_h$` = sprintf("%.1f", gamma),
         `Multiplies a catchment school's pull by` = sprintf("%.1f×", exp(gamma))) %>%
  arrange(desc(gamma)) %>%
  select(Catchment, `Fitted $\\gamma_h$`, `Multiplies a catchment school's pull by`) %>%
  knitr::kable(align = "lrr")
The catchment term fitted for each catchment, and what it does to a school’s pull. A term of 0 would mean the map makes no difference to what families name.
Catchment Fitted \(\gamma_h\) Multiplies a catchment school’s pull by
Varndean / Dorothy Stringer 2.6 13.5×
Longhill 2.3 10.0×
PACA (Portslade Aldridge) 1.7 5.5×
Hove Park / Blatchington Mill 1.6 5.0×
Patcham 1.5 4.5×
BACA (Brighton Aldridge) 1.0 2.7×
Show code
gsf <- catch_sf %>%
  left_join(tibble(catchment = unname(CATCH_FROM_MODEL[names(gam)]),
                   gamma = as.numeric(gam)), by = "catchment")
ggplot() +
  geom_sf(data = gsf, aes(fill = gamma), colour = "white", linewidth = 0.5) +
  geom_sf(data = sch_sf, shape = 21, fill = "white", colour = "black", size = 2.6) +
  geom_sf_text(data = gsf %>% st_point_on_surface() %>% suppressWarnings(),
               aes(label = sprintf("%.1f", gamma)), size = 5, fontface = "bold",
               colour = "white", nudge_y = 0.006) +
  scale_fill_viridis_c(option = "mako", direction = -1, name = "γ") +
  coord_sf(expand = TRUE) +
  labs(title = "How closely each catchment's families follow their map",
       subtitle = "Fitted to the council's catchment-level preference table") +
  theme_void(12) +
  theme(plot.title = element_text(face = "bold", colour = "#1f3b57"),
        plot.subtitle = element_text(colour = "grey35"))
Figure 12: The fitted catchment term by catchment. Darker is a population that follows its map more closely, once distance and attractiveness are accounted for.

Stringer / Varndean, at 2.6, and Longhill, at 2.3, are the two most attached populations in the city; Brighton Aldridge’s, at 1.0, the least. Those two facts do much of the work in every relocation and redraw result in the strategic view, because a move trades one against the other.

6.3 How they are fitted

The council’s evidence to the Schools Adjudicator includes a catchment-level preference table: how many first, second and third preferences each catchment’s children gave each school, pooled over 3 rounds. That is a flow matrix — coarse, but a matrix — and it is enough to fit one term per catchment.

The loop is:

  1. Take the current catchment terms and balance \(W_j\) so the model reproduces each school’s share of the city’s first preferences (§3).
  2. For one catchment, try every \(\gamma\) on a grid from 0 to 4 in steps of 0.1, re-balancing \(W_j\) at each step, and keep the value that minimises the Poisson deviance between the modelled and observed shares in that table.
  3. Repeat for each catchment, then sweep again until nothing moves.

The deviance is the right loss here because the cells are counts of preferences: it weights a cell by how many families it contains, so a catchment that gives 400 first preferences counts for more than one that gives 40.

Show code
cal$profile %>%
  mutate(Catchment = unname(gam_lab[catchment])) %>%
  group_by(Catchment) %>%
  mutate(rel = deviance - min(deviance)) %>%
  ungroup() %>%
  ggplot(aes(gamma, rel)) +
  geom_line(colour = "#1f3b57", linewidth = 0.9) +
  geom_point(data = ~ .x %>% group_by(Catchment) %>% slice_min(rel, n = 1),
             colour = "#d95f02", size = 2.6) +
  facet_wrap(~ Catchment, scales = "free_y") +
  labs(x = "Catchment term (γ)", y = "Deviance above the best fit",
       title = "Each catchment's own curve, and where it bottoms out",
       subtitle = "Lower is a better fit to the council's preference table") +
  theme_bh(11)
Figure 13: The fit against each candidate value, catchment by catchment. The point is the value chosen: the bottom of each curve. A flat curve would mean the data cannot distinguish one value from another.

6.4 What the fitting buys

Show code
cal$fit %>%
  transmute(Model = ifelse(model == "M4", "One term for the whole city (M4)",
                           "One term per catchment (M5)"),
            `Poisson deviance` = fmt_n(deviance),
            `Cell error (percentage points)` = sprintf("%.1f", cell_rmse_pp)) %>%
  knitr::kable(align = "lrr")
How well each specification reproduces the council’s preference table. M4 is one catchment term for the whole city, fitted against offers; M5 is one per catchment, fitted against preferences.
Model Poisson deviance Cell error (percentage points)
One term for the whole city (M4) 2,858 7.8
One term per catchment (M5) 697 3.3
Show code
cal$own %>%
  mutate(Catchment = unname(gam_lab[home])) %>%
  tidyr::pivot_longer(c(observed, M4, M5), names_to = "src", values_to = "share") %>%
  mutate(src = recode(src, observed = "Observed",
                      M4 = "One term for the city", M5 = "One term per catchment"),
         src = factor(src, c("Observed", "One term for the city", "One term per catchment")),
         Catchment = forcats::fct_reorder(Catchment, share, .fun = max)) %>%
  ggplot(aes(share, Catchment, colour = src, shape = src)) +
  geom_point(size = 3.6) +
  scale_colour_manual(values = c("#1f3b57", "grey65", "#d95f02"), name = NULL) +
  scale_shape_manual(values = c(16, 15, 17), name = NULL) +
  scale_x_continuous(labels = label_percent()) +
  labs(x = "Share of the catchment's first preferences going to its own school", y = NULL,
       title = "A single city-wide term cannot reproduce the pattern",
       subtitle = "Fitting one term per catchment does, and it is the same model otherwise") +
  theme_bh() + theme(legend.position = "top")
Figure 14: The share of each catchment’s first preferences that go to its own school or schools: what the council’s table records, and what each specification produces.

The gain is large and it is concentrated where it matters. A single city-wide term puts Stringer / Varndean’s own-school share at 61% against an observed 81%, and Longhill’s at 18% against 40%. Fitting each catchment separately reproduces both.

6.5 The two schools with no catchment at all

Cardinal Newman and King’s are in no catchment, on any of the maps the simulator carries. That is not a simplification: neither school has one. Admission to Cardinal Newman turns on Catholic baptism and practice, and King’s is a Church of England free school with its own criteria and no geographic area attached.

So \(\gamma_h\) never applies to them. Every one of the 866 neighbourhood-to-faith-school pairs in the model has the indicator \(\mathbb{1}[\,i \in \text{catchment}(j)\,]\) set to zero, and their pull is \(W_j\, c_{ij}^{-\beta}\, C_j^{\delta}\) and nothing else. Four consequences follow, and they are worth being explicit about: between them these two schools take 24% of the city’s Year 7 children.

Their attractiveness carries the religion. A family’s faith is not in this model — no published dataset gives it by neighbourhood. What the fitting sees is that a great many families across the whole city name these schools first anyway, and the only term free to explain it is \(W_j\). So Cardinal Newman ends up with the highest attractiveness in the city (3.27 against a mean of 1) and King’s the second (2.57). Read those two numbers as “wanted, city-wide, for reasons the model does not contain” rather than as a judgement of the school.

Distance is the only thing holding them in place. With no catchment bonus, \(c_{ij}^{-\beta}\) is the sole spatial control on where their children come from — which is why they draw from everywhere and why their intakes travel furthest:

Show code
faith_mix %>%
  mutate(School = short_sch(name),
         School = factor(School, c("Cardinal Newman", "King's", "Varndean", "Longhill")),
         Catchment = catch_lab(catchment)) %>%
  ggplot(aes(share, forcats::fct_rev(School), fill = Catchment)) +
  geom_col(width = 0.7, colour = "white", linewidth = 0.4) +
  scale_x_continuous(labels = scales::percent) +
  scale_fill_brewer(palette = "Set2", name = "Home catchment") +
  labs(x = NULL, y = NULL,
       title = "Two schools drawn from the whole city, two from one corner of it",
       subtitle = "Modelled intake by the family's home catchment, 2026 entry") +
  theme_bh() + theme(legend.position = "right")
Figure 15: Where each school’s intake comes from, by the family’s home catchment. The two faith schools have no catchment of their own, so nothing anchors them to one part of the city.

The children the model sends to King’s travel 42 minutes on average and to Cardinal Newman 33, against 19 to 32 minutes at the eight community and academy schools. That is a real feature of faith admissions, and the model reproduces it without being told to.

They are outside the catchment machinery everywhere else too. The priority-6 rule — a child from a single-school catchment applying outside it — is defined on the catchment design, so it neither sends children to these two nor gives them any standing there. When a boundary is redrawn in a scenario, nothing about Cardinal Newman or King’s changes except through everyone else’s movements. And because they are not community schools, they are rationed by the proportional ceiling rather than by the council’s tiers (§9): the model has no representation of baptism certificates or church attendance, so it treats an oversubscribed faith school as a lottery over everyone who applied.

They still take part in everything that is not the catchment. They compete in \(C_j\) (§7) — Cardinal Newman is the second largest contribution to Dorothy Stringer’s competition term — they sit in every neighbourhood’s choice set and denominator (§8), and families do name them as second preferences, so refused children are re-offered places there: 19% of a catchment’s second preferences go to one of the two on average (§10).

WarningWhat this means for a faith-school scenario

Because the religion is inside \(W_j\) rather than in a term of its own, the simulator cannot distinguish “Cardinal Newman becomes less attractive” from “fewer families in the city are practising Catholics”. Both are the same slider. A scenario that changes either should be read as changing the total pull of the school, and the model will redistribute those children by distance and catchment like anyone else’s — which is almost certainly too smooth a picture of what a change in faith admissions would actually do.

6.6 What the slider does

The simulator’s catchment slider multiplies every fitted term by the same factor, from 0 to 2. At 0 the map stops mattering to what families name and the model becomes distance and attractiveness alone; at 2 every catchment’s attachment doubles in the exponent, which is a very strong map indeed. At 1 it uses the fitted values.

That single multiplier is deliberately blunt. It answers “what if families follow their catchments more, or less, than they do now?” without pretending to know which catchment would change.

Show code
purrr::map_dfr(c(0, 0.5, 1, 1.5, 2), function(g) {
  rr <- run_sim(inp, year = 2026, gamma = g, rules = list(rule = "priorities"))
  tibble(`Slider` = sprintf("%.1f×", g),
         `At a school in their own catchment` = fmt_pct(100 * rr$in_catch_share, 0))
}) %>%
  knitr::kable(align = "lr")
What the slider does to the share of children modelled at a school in their own catchment, 2026, council priorities.
Slider At a school in their own catchment
0.0× 41%
0.5× 50%
1.0× 58%
1.5× 67%
2.0× 75%

6.7 Where it sits in the matrix

Unlike \(O_i\) and \(W_j\), the catchment term does not live in a margin. It is a mask over the interior: for each neighbourhood, the cells of schools in its own catchment are multiplied by \(e^{\gamma_h}\) and the rest are left alone.

Show code
dsg <- inp$designs[["Current catchments"]]
cellmark <- function(zone, school) {
  in_c <- !is.na(dsg$school[school]) && dsg$school[school] == dsg$zone[zone]
  h <- unname(dsg$zone[zone])
  if (in_c) sprintf("$T_{ij} \\times %.1f$", exp(gam[[h]])) else "$T_{ij}$"
}
data.frame(
  Neighbourhood = c(sub("[|].*$", "", ex_zones$zone), "$W_j$"),
  `Dorothy Stringer` = c(vapply(ex_zones$zone, cellmark, "", school = sch4[1]),
                         sprintf("%.2f", W5[[sch4[1]]])),
  Varndean = c(vapply(ex_zones$zone, cellmark, "", school = sch4[2]),
               sprintf("%.2f", W5[[sch4[2]]])),
  Longhill = c(vapply(ex_zones$zone, cellmark, "", school = sch4[3]),
               sprintf("%.2f", W5[[sch4[3]]])),
  `Cardinal Newman` = c(vapply(ex_zones$zone, cellmark, "", school = sch4[4]),
                        sprintf("%.2f", W5[[sch4[4]]])),
  `$O_i$ (2026)` = c(sprintf("%.1f", ex_zones$Oi), ""),
  check.names = FALSE) %>%
  knitr::kable(align = "lccccr")
The four neighbourhoods again. A cell marked with the multiplier is one where the school is in that neighbourhood’s catchment; the others are untouched.
Neighbourhood Dorothy Stringer Varndean Longhill Cardinal Newman \(O_i\) (2026)
E01016901|DS_Varndean|DS_Varndean E01016901 \(T_{ij} \times 13.5\) \(T_{ij} \times 13.5\) \(T_{ij}\) \(T_{ij}\) 29.8
E01016880|Hove_Blatch|Hove_Blatch E01016880 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) 27.1
E01016979|Hove_Blatch|Hove_Blatch E01016979 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) 25.5
E01016997|Hove_Blatch|Hove_Blatch E01016997 \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) \(T_{ij}\) 24.3
\(W_j\) 0.55 1.03 0.10 3.27

Cardinal Newman is in nobody’s catchment: it and King’s admit city-wide on faith criteria, so no cell in their columns is ever multiplied. In the two paired catchments both schools are in catchment, which is what makes those catchments different objects from the four single-school ones.

6.8 Checks you can run

Show code
prof_flat <- cal$profile %>% group_by(catchment) %>%
  summarise(span = diff(range(deviance)), .groups = "drop")
tibble::tribble(
  ~Check, ~Result,
  "Every catchment's fit has an interior minimum",
  sprintf("fitted %.1f to %.1f, grid 0 to 4", min(gam), max(gam)),
  "The data can tell one value from another",
  sprintf("deviance spans %s to %s across the grid",
          fmt_n(min(prof_flat$span)), fmt_n(max(prof_flat$span))),
  "Fitting per catchment beats one term for the city",
  sprintf("deviance %s against %s", fmt_n(cal$fit$deviance[cal$fit$model == "M5"]),
          fmt_n(cal$fit$deviance[cal$fit$model == "M4"])),
  "Modelled children at a catchment school, today",
  fmt_pct(100 * r_now$in_catch_share, 0),
  "Slider at zero removes the map entirely",
  fmt_pct(100 * run_sim(inp, year = 2026, gamma = 0,
                        rules = list(rule = "priorities"))$in_catch_share, 0)) %>%
  knitr::kable(align = "lr")
What the catchment terms should satisfy.
Check Result
Every catchment’s fit has an interior minimum fitted 1.0 to 2.6, grid 0 to 4
The data can tell one value from another deviance spans 117 to 1,145 across the grid
Fitting per catchment beats one term for the city deviance 697 against 2,858
Modelled children at a catchment school, today 58%
Slider at zero removes the map entirely 41%

6.9 What to be careful about

WarningThe assumption every redraw result depends on

The terms are fitted on the map families have lived with, and then carried unchanged to any other map. A redraw in the simulator moves which schools count as in catchment; it does not change how strongly a family follows the line.

That is the single most consequential assumption in this work. If families follow a new boundary less closely than the one they know — which the strategic view finds some evidence for in what happened after 2024 — then every redraw result here is too optimistic about how many children the redraw moves. The strategic view reports the halved-term version of the relocation runs for exactly this reason.

  • The term is fitted on the pre-2024 map, because that is the map the preference table covers.
  • It bundles several mechanisms. Siblings already at the school, the free bus, habit, and the sense that the catchment school is the school all end up in one number, because the table cannot separate them.
  • It is a home-catchment term, not a school term. A school in two catchments — none exist today, but CoMArt opening would create one — takes each catchment’s own strength.
  • Paired catchments are a single object here. A Stringer / Varndean family gets the bonus on both schools, and which of the two they prefer is left to \(W_j\), distance, and the exclusivity populations.

6.10 Questions this raises

  1. Should the term decay when a boundary changes — a family newly placed in a catchment following it less closely than one that has been in it for years?
  2. Should it vary with how many schools a catchment contains, rather than being one number per catchment?
  3. Would first preferences by small area, before and after the 2024 change, let the normative half of this be separated from the procedural half?

7 \(C_j\) and \(\delta\): the company a school keeps

7.1 What it is

\(C_j\) measures how much company a school has: how many other schools stand close to it, weighted by how wanted each of them is.

\[C_j \;=\; \sum_{k \neq j} W_k\, d_{jk}^{-\sigma}, \qquad \sigma = 1\]

\(d_{jk}\) is the straight-line distance in kilometres between two schools, and it deliberately carries a different symbol from \(c_{ij}\): nobody travels from one school to another, so routing it would mean nothing. All that is wanted is a measure of how clustered the schools are. With \(\sigma\) at 1 the term reduces to the sum of the rivals’ attractiveness per kilometre they are away.

It enters the model as a power on the pull:

\[\text{pull} \;\propto\; W_j\, c_{ij}^{-\beta}\, e^{\gamma_{h}\mathbb{1}[\cdot]}\, C_j^{\,\delta}, \qquad \delta = +0.3\]

The reasoning is Fotheringham’s: a plain gravity model is misspecified when destinations cluster, because people choose an area first and a destination within it second. Left out, the clustering does not go away — it is absorbed by the distance parameter instead.

7.2 Brighton in one term

Show code
sch_pts <- sch_city %>%
  select(name, easting, northing) %>%
  st_as_sf(coords = c("easting", "northing"), crs = 27700) %>%
  st_transform(4326) %>%
  mutate(short = short_sch(name),
         lon = st_coordinates(.)[, 1], lat = st_coordinates(.)[, 2]) %>%
  left_join(Cj %>% select(name, C, index), by = "name")
lonlat <- sch_pts %>% st_drop_geometry() %>% select(name, lon, lat)
segs <- pairs_km %>%
  filter(short %in% c("Dorothy Stringer", "Longhill")) %>%
  left_join(lonlat, by = "name") %>%
  left_join(lonlat %>% rename(k = name, klon = lon, klat = lat), by = "k")

ggplot() +
  geom_sf(data = catch_sf, fill = "grey96", colour = "white", linewidth = 0.5) +
  geom_segment(data = segs, aes(x = lon, y = lat, xend = klon, yend = klat,
                                linewidth = contrib, colour = short), alpha = 0.5) +
  geom_sf(data = sch_pts, aes(size = C), shape = 21, fill = "white",
          colour = "black", stroke = 0.9) +
  ggrepel::geom_text_repel(
    data = sch_pts, aes(lon, lat, label = sprintf("%s\n%.0f", short, index)),
    size = 2.9, lineheight = 0.9, colour = "#1f3b57", seed = 11,
    box.padding = 0.45, point.padding = 0.3, min.segment.length = 0.2,
    segment.colour = "grey55", segment.size = 0.3, max.overlaps = Inf) +
  scale_linewidth_continuous(range = c(0.2, 3.4), guide = "none") +
  scale_size_continuous(range = c(2, 9), guide = "none") +
  scale_colour_manual(values = c(`Dorothy Stringer` = "#d95f02", Longhill = "#1f3b57"),
                      name = NULL,
                      labels = function(x) paste("contributions to", x)) +
  coord_sf(expand = TRUE, clip = "off") +
  labs(title = "Five hundred metres from a rival, or five kilometres",
       subtitle = "The competing-destinations term by school, city average = 100") +
  theme_void(12) +
  theme(plot.title = element_text(face = "bold", colour = "#1f3b57"),
        plot.subtitle = element_text(colour = "grey35"),
        legend.position = "top")
Figure 16: Each school sized by the company it keeps, with its score on an index where the city average is 100. The lines open up the two extremes: every rival’s contribution to Dorothy Stringer’s total in orange, and to Longhill’s in navy. A thicker line is a bigger contribution.

The two ends of the city are different objects on this measure.

Show code
pairs_km %>%
  filter(short %in% c("Dorothy Stringer", "Longhill")) %>%
  group_by(short) %>% arrange(desc(contrib), .by_group = TRUE) %>%
  mutate(rank = row_number(), total = sum(contrib)) %>%
  filter(rank <= 3) %>% ungroup() %>%
  transmute(School = sprintf("%s (total %.2f)", short, total),
            Rival = k_short, `Distance (km)` = sprintf("%.2f", km),
            `Its $W_k$` = sprintf("%.2f", Wk),
            Contribution = sprintf("%.2f", contrib)) %>%
  knitr::kable(align = "llrrr")
What the term is made of at its two extremes. Each row is one rival: how far away it is, how wanted it is, and what it therefore contributes.
School Rival Distance (km) Its \(W_k\) Contribution
Dorothy Stringer (total 5.98) Varndean 0.47 1.03 2.20
Dorothy Stringer (total 5.98) Cardinal Newman 1.67 3.27 1.95
Dorothy Stringer (total 5.98) King’s 3.45 2.57 0.75
Longhill (total 1.36) Cardinal Newman 6.68 3.27 0.49
Longhill (total 1.36) King’s 9.60 2.57 0.27
Longhill (total 1.36) Varndean 6.03 1.03 0.17

Varndean stands 467 metres from Dorothy Stringer, and Cardinal Newman — the most wanted school in the city on \(W_j\) — is under two kilometres from both. Those two rivals alone contribute more to Stringer’s term than all nine of Longhill’s rivals contribute to its own. On an index with the city average at 100, Stringer scores 166 and Longhill 38.

Two features of the term follow from how it is built, and both matter in the simulator:

  • It uses the attractiveness in force, not a fixed table. Pull an attractiveness slider and every other school’s \(C_j\) moves too, because \(W_k\) sits inside the sum.
  • It is a sum over the schools that exist in a run. Close a school and its neighbours lose the company it was providing; open CoMArt in East Brighton and Longhill gains some.

7.3 What the fitted exponent does

\(\delta\) was fitted by grid search over -1.5 to +1.5, against the offers the council made in the most recent round, with the capacity ceiling and the catchment term already in place (§6 explains why the ceiling goes in first).

Show code
dprof <- bind_rows(
  mt$delta_fit   %>% mutate(v = "With the ceiling and the catchment term"),
  mt$delta_alone %>% mutate(v = "With the ceiling only"),
  mt$delta_nocap %>% mutate(v = "With neither")) %>%
  group_by(v) %>% mutate(rel = rmse / rmse[abs(value) < 1e-8]) %>% ungroup() %>%
  mutate(v = factor(v, c("With the ceiling and the catchment term",
                         "With the ceiling only", "With neither")))
ggplot(dprof, aes(value, rel, colour = v)) +
  annotate("rect", xmin = min(dprof$value), xmax = 0, ymin = -Inf, ymax = Inf,
           fill = "#b2182b", alpha = 0.05) +
  geom_hline(yintercept = 1, colour = "grey60", linewidth = 0.4) +
  geom_line(linewidth = 1) +
  geom_point(data = dprof %>% group_by(v) %>% slice_min(rel, n = 1) %>% ungroup(),
             size = 3, show.legend = FALSE) +
  annotate("text", x = min(dprof$value) + 0.05, y = 1.55, hjust = 0, vjust = 1,
           size = 3.5, colour = "#b2182b", lineheight = 0.95,
           label = "rivals take share\n(competing destinations)") +
  scale_colour_manual(values = c("#1f3b57", "#d95f02", "grey60"), name = NULL) +
  coord_cartesian(ylim = c(0.8, 1.6)) +
  labs(x = "δ", y = "Error, relative to the same model at δ = 0",
       title = sprintf("Fitted at δ = %+.1f, on the wrong side of zero", DELTA),
       subtitle = "Dots mark each version's best value") +
  theme_bh() +
  theme(legend.position = "top", legend.direction = "vertical",
        legend.justification = "left")
Figure 17: How much the term improves the fit, for three versions of the model. Each curve is that version’s error against the offers made, relative to itself at δ = 0, so below 1 means the term is earning its place. The shaded half is the sign Fotheringham’s argument predicts.

The sign is not the one the term was added to test. A negative \(\delta\) is the competing-destinations prediction: rivals nearby take share. The fit lands at +0.3 — a school does slightly better for having company. That is agglomeration, the pattern of retail parks and restaurant streets, rather than the correction Fotheringham proposed. Negative values are not marginally worse but much worse: at \(\delta = -1\) the error is about 3.1 times its best.

Three things keep the finding modest:

  • The gain is small. RMSE falls from 22.9 at \(\delta = 0\) to 21.4 at the fitted value, and \(R^2\) from 0.945 to 0.947.
  • Without the ceiling it does nothing at all. Fitted with no capacity constraint the best value is +0.1 and the error moves only from 68.3 to 68.2 — the grey curve above, which barely dips below its own starting point. What the term appears to explain is mostly rationing.
  • It and \(\gamma\) are partly substitutes. With the ceiling but no catchment term, \(\delta\) on its own fits at +0.3 and reaches much the same error. Both terms hand extra pull to the same central schools by different routes, so neither is a clean estimate of its own mechanism.
Show code
Cj %>% arrange(desc(index)) %>%
  transmute(School = short,
            `$C_j$ (city average = 100)` = sprintf("%.0f", index),
            `Multiplies its pull by` = sprintf("%.2f", mult),
            `$C_j$ with Longhill at Elm Grove` = sprintf("%.0f", index_elm)) %>%
  knitr::kable(align = "lrrr")
What the term does to each school’s pull at the fitted exponent. A multiplier of 1 would be no effect. The last column is the same index with Longhill rebuilt at Elm Grove, which changes the distances every school is measured against.
School \(C_j\) (city average = 100) Multiplies its pull by \(C_j\) with Longhill at Elm Grove
Dorothy Stringer 166 1.17 167
Hove Park 157 1.14 157
Blatchington Mill 153 1.14 153
Varndean 124 1.07 125
Patcham 95 0.98 95
Cardinal Newman 73 0.91 73
King’s 72 0.91 72
Portslade Aldridge 68 0.89 68
Brighton Aldridge 54 0.83 54
Longhill 38 0.75 70

So the effect on pull runs from about 0.75 for the most isolated school to 1.17 for the most crowded: Longhill’s pull is cut by roughly 25% against the city average and Stringer’s raised by about 17%. Smaller than \(\gamma\) or \(\beta\), but not nothing.

7.4 Why it is in the model at all

Three reasons, none of them “it improved the fit a lot”:

  1. It is the standard correction for a gravity model over clustered destinations. Leaving it out does not remove the clustering; it loads it onto \(\beta\), and the distance parameter then means two things at once.
  2. It moves when a school moves. Rebuilt at Elm Grove, Longhill’s term rises from 38 to 70 — still below average, but no longer alone on the eastern edge. That is a real difference between the two sites and the model should see it. It is also a correction made in this version: before it, competition was measured from Ovingdean even in runs where the school had moved.
  3. It records a negative finding. That a compact city of ten schools shows no competing-destinations effect is worth reporting, and it is at least consistent with the structure: a production-constrained model already normalises each neighbourhood’s choices across every school through \(A_i\) (§8), which is where much of the work the term would otherwise do has already been done.

7.5 Checks you can run

Show code
tibble::tribble(
  ~Check, ~Result,
  "Schools in the sum",
  sprintf("%d city schools; out-of-city destinations carry no C_j at all",
          nrow(sch_city)),
  "Distances are school to school, straight line",
  sprintf("%.2f km to %.2f km", min(pairs_km$km), max(pairs_km$km)),
  "Closest pair in the city",
  sprintf("%s and %s, %.0f m", pairs_km$short[which.min(pairs_km$km)],
          pairs_km$k_short[which.min(pairs_km$km)], 1000 * min(pairs_km$km)),
  "Most isolated school",
  sprintf("%s, nearest rival %.1f km", Cj$short[which.min(Cj$C)],
          min_km(Cj$short[which.min(Cj$C)])),
  "Rivals are attractiveness-weighted",
  "yes: an unwanted neighbour counts for less than a wanted one",
  "Effect on pull at the fitted exponent",
  sprintf("%.2f to %.2f times", min(Cj$mult), max(Cj$mult)),
  "Measured from where each school stands in the run",
  sprintf("yes: Longhill scores %.0f at Ovingdean and %.0f at Elm Grove",
          cj_of("Longhill", "index"), cj_of("Longhill", "index_elm"))) %>%
  knitr::kable(align = "lr")
What the competing-destinations term should satisfy.
Check Result
Schools in the sum 10 city schools; out-of-city destinations carry no C_j at all
Distances are school to school, straight line 0.47 km to 11.70 km
Closest pair in the city Varndean and Dorothy Stringer, 467 m
Most isolated school Longhill, nearest rival 4.7 km
Rivals are attractiveness-weighted yes: an unwanted neighbour counts for less than a wanted one
Effect on pull at the fitted exponent 0.75 to 1.17 times
Measured from where each school stands in the run yes: Longhill scores 38 at Ovingdean and 70 at Elm Grove

7.6 What to be careful about

  • \(\sigma\) is set, not fitted. The decay on school-to-school distance is fixed at 1. Fitting it as well as \(\delta\) on ten destination margins would be asking too much of the data.
  • Ten schools. Every statement here rests on ten points, and two tight pairs — Stringer with Varndean, Blatchington Mill with Hove Park — dominate the variation.
  • It is entangled with \(W_j\) and with \(\gamma\). The sum uses the attractiveness that was itself balanced with this term in place, and §6’s catchment term can do much of the same work.
  • Straight lines, not journeys. Two schools half a kilometre apart across a railway are as close, on this measure, as two half a kilometre apart on the same street.
  • Fitted on offers, not preferences. Unlike \(\gamma_h\), which was refitted against the catchment preference table, \(\delta\) was fitted against the offers made — and those are shaped by the ceiling as much as by choice.

7.7 Questions this raises

  1. Should \(\delta\) be refitted against preferences, as \(\gamma_h\) now is? That is the cleaner question, and this is the last term still fitted against the older target.
  2. Is the positive sign real, or is it standing in for something the model is missing — the faith schools’ city-wide draw, say, which happens to sit in the crowded middle of the city?
  3. Would a nested form — choose an area, then a school within it — represent the paired catchments better than an exponent on clustering does?

8 \(A_i\): the balancing factor

8.1 What it is

\(A_i\) is not a thing anyone chooses or observes. It is whatever number makes each neighbourhood’s row of the matrix add up to the children who live there:

\[\sum_j T_{ij}^{\,y} = O_i^{\,y} \quad\Longrightarrow\quad A_i^{\,y} \;=\; \frac{1}{\sum_k W_k\, c_{ik}^{-\beta}\, e^{\gamma_{h(i)}\mathbb{1}[\cdot]}\, C_k^{\delta}}\]

Call the denominator the neighbourhood’s total pull: everything the city offers it, discounted by how hard each school is to reach. \(A_i\) is its reciprocal, and the model then divides the children of \(i\) between schools in proportion to each school’s share of that total:

\[T_{ij} \;=\; O_i \times \frac{\text{pull}_{ij}}{\sum_k \text{pull}_{ik}}\]

This is what “production-constrained” means, and it is the single strongest assumption in the model. Every child is placed somewhere. Demand cannot leak away, and it cannot grow because the city got better: if every school became less attractive tomorrow, \(A_i\) would simply rise to compensate and exactly the same children would be distributed in exactly the same proportions.

8.2 Computed per population, not per zone

One subtlety: the model does not balance over zones but over origin-populations. In the two paired catchments some families will take one of the pair but not the other, so a zone there carries up to three populations with different choice sets, each balanced on its own.

Show code
d_ai %>% distinct(orig, pop, zone) %>%
  count(pop, name = "Origins") %>%
  mutate(Population = ifelse(pop == "either", "Everyone else", pop)) %>%
  select(Population, Origins) %>%
  knitr::kable(align = "lr")
The origin-populations the model balances over. Each is a group of children with its own choice set, and therefore its own balancing factor.
Population Origins
Everyone else 195
only Blatchington Mill School 61
only Dorothy Stringer School 58
only Hove Park School 61
only Varndean School 58

That is 433 origins over 195 zones, and it matters because a child who would not take Varndean must have Varndean out of the denominator, not merely low in it. Otherwise refusing them at Dorothy Stringer would send them next door.

8.3 What the total pull looks like

Show code
pull_map <- lsoa_sf %>%
  inner_join(pull_z %>% inner_join(zone_lsoa %>% select(zone, lsoa), by = "zone") %>%
               group_by(lsoa) %>%
               summarise(index = weighted.mean(index, Oi), .groups = "drop"),
             by = c("lsoa21cd" = "lsoa"))
ggplot() +
  geom_sf(data = pull_map, aes(fill = index), colour = NA) +
  geom_sf(data = catch_sf, fill = NA, colour = "white", linewidth = 0.3) +
  geom_sf(data = sch_sf, shape = 21, fill = "white", colour = "black",
          size = 2.4, stroke = 0.9) +
  scale_fill_viridis_c(option = "viridis", trans = "log10",
                       name = "Total pull\n(median = 100)",
                       breaks = c(30, 100, 300, 1000)) +
  labs(title = "What each neighbourhood has within reach",
       subtitle = "The sum the balancing factor divides by, current catchment map, 2026") +
  theme_void(12) +
  theme(plot.title = element_text(face = "bold", colour = "#1f3b57"),
        plot.subtitle = element_text(colour = "grey35"))
Figure 18: The denominator of the balancing factor: how much school, weighted by how reachable it is, each neighbourhood has available. Drawn on a log scale, because the range is more than eightyfold. Zone values are carried up to LSOAs, weighted by children.

The range is extreme — from 24 to 3162 on an index with the median at 100 — and most of it is the catchment term compounding with distance. A neighbourhood between Dorothy Stringer and Varndean has two schools it can walk to and the catchment bonus on both. A neighbourhood in Ovingdean has one school within easy reach, with the lowest \(W_j\) in the city.

For the far east of the city the denominator also contains schools outside it. In a typical Longhill-catchment neighbourhood they are 15.3% of the total pull, and they are under 2.7% everywhere west of Brighton Aldridge’s.

8.4 Two rows, worked

Show code
ai_tbl(ZC) %>% knitr::kable(align = "lrrlrrr")
A neighbourhood between the two Fiveways schools. Top six destinations of fourteen; the rest are rounding.
School Journey (min) \(W_j\) In catchment Pull Share of the row Children
Varndean 13 1.03 yes 2.73e-01 70.5% 19.0
Dorothy Stringer 17 0.55 yes 9.70e-02 25.0% 6.7
Cardinal Newman 37 3.27 9.18e-03 2.4% 0.6
Patcham 35 0.78 2.71e-03 0.7% 0.2
King’s 71 2.57 2.44e-03 0.6% 0.2
Brighton Aldridge 37 0.48 1.23e-03 0.3% 0.1

27 children, total pull 0.387, so \(A_i = 2.58\).

Show code
ai_tbl(ZE) %>% knitr::kable(align = "lrrlrrr")
A neighbourhood in Longhill’s catchment. The same table, the same city, a different world.
School Journey (min) \(W_j\) In catchment Pull Share of the row Children
Longhill 17 0.10 yes 9.21e-03 39.8% 9.1
Priory 92 — 4.39e-03 19.0% 4.3
Cardinal Newman 68 3.27 3.32e-03 14.3% 3.3
King’s 86 2.57 1.77e-03 7.6% 1.7
Varndean 62 1.03 1.43e-03 6.2% 1.4
Patcham 75 0.78 7.38e-04 3.2% 0.7

23 children, total pull 0.0232, so \(A_i = 43.2\) — about 17 times the first.

Read the two tables side by side and the model’s behaviour is visible in one page. In the first, 95% of the row goes to the two catchment schools before anything else is considered. In the second, the catchment school takes 40%, and the second destination is Priory School in Lewes, outside the city altogether.

NoteThe balancing factor is not the accessibility index, but it is close

The strategic view reports an accessibility index for each neighbourhood. That is the same sum with the behavioural terms stripped out — attractiveness and journey time only, no catchment bonus, no competition term, city schools alone. The two rank neighbourhoods similarly but not identically (Spearman \(\rho = 0.84\)), and the difference is mostly the catchment term: it rewards the neighbourhoods that are already close to a wanted school.

8.5 Where it sits in the sequence

\(A_i\) is applied to the uncapped pull, before any admission number is consulted. The model first works out what families want, and only then rations it (§9), re-offering the children who are refused (§10).

The constraint survives that. After the ceiling and the re-offering, the row still sums to the children who live there — nobody is lost in rationing, they are only placed elsewhere:

Show code
tibble::tribble(
  ~Check, ~Result,
  "Origins balanced",
  sprintf("%s populations over %s zones", fmt_n(nrow(row_chk)),
          fmt_n(n_distinct(d_ai$zone))),
  "Rows sum to their children, before the ceiling",
  sprintf("largest error %.0e children", max(abs(row_chk$wanted - row_chk$Oi))),
  "Rows still sum to their children, after it",
  sprintf("largest error %.2f children", max(abs(row_chk$final - row_chk$Oi))),
  "City total placed",
  sprintf("%s children in 2026, all of them", fmt_n(round(sum(d_ai$flow)))),
  "Recomputing the flows by hand reproduces the engine",
  sprintf("largest difference %.0e children", ai_gap),
  "Range of the balancing factor",
  sprintf("%.2f to %.1f", min(pull_z$A), max(pull_z$A)),
  "Out-of-city schools are inside the denominator",
  "yes: they compete for children like any other destination") %>%
  knitr::kable(align = "lr")
What the balancing factor should satisfy. Every number here is computed from the run, not asserted.
Check Result
Origins balanced 433 populations over 195 zones
Rows sum to their children, before the ceiling largest error 4e-15 children
Rows still sum to their children, after it largest error 0.00 children
City total placed 2,267 children in 2026, all of them
Recomputing the flows by hand reproduces the engine largest difference 2e-15 children
Range of the balancing factor 0.52 to 67.5
Out-of-city schools are inside the denominator yes: they compete for children like any other destination

8.6 What to be careful about

  • No demand response. Because the row is fixed at \(O_i\), nothing the model does can make families leave the state system, home-school, move house, or appear from elsewhere. A scenario that makes every school worse produces the same 2,267 placements as one that makes every school better. Where a scenario would plausibly change how many families opt out, the model will not show it, and §2’s state-sector share is the only place that quantity lives.
  • Substitution is proportional. Close a school, and the children who wanted it are redistributed among the others in proportion to those schools’ existing pull for that neighbourhood — the classic independence-of-irrelevant-alternatives behaviour. The exclusivity populations are a patch on exactly this, for the one case where it is known to be wrong.
  • It absorbs everything the model does not contain. Any systematic pull the equation misses is silently rescaled away by \(A_i\) rather than showing up as a residual. A model that fits well at the school level can still be wrong about why.
  • It is recomputed in every run. Change a year, a slider, a boundary or a school’s existence and the denominator changes for every affected neighbourhood. \(A_i\) is never carried over between runs.

8.7 Questions this raises

  1. Should the state-sector share respond to the scenario rather than being fixed in \(O_i\) — a doubly-constrained or elastic form?
  2. Should the choice set be truncated — some neighbourhoods carry fourteen destinations when families realistically consider three or four?
  3. Are the exclusivity populations the right patch for proportional substitution, or should the whole thing be nested?

9 The admission-number ceiling

9.1 What happens after the matrix

Sections 2 to 8 produce \(T_{ij}\): how many children in each neighbourhood want each school as their first choice. Nothing in that calculation knows how many places a school has. The city’s demand, laid against the admission numbers and against what families actually named, does not fit:

Show code
pair_either <- d_ai %>%
  filter(pop == "either", catchment == "DS_Varndean", name == "Varndean School") %>%
  summarise(Varndean = sum(wanted))
prefs26 <- bh_data("factsheet_panel.rds")$factsheets %>%
  filter(year == 2026, name != "Total") %>%
  transmute(name, pref1, pref12 = pref1 + pref2, off1)
dem <- by_sch %>%
  left_join(prefs26, by = "name") %>%
  mutate(short = forcats::fct_reorder(short, pan))
dem_long <- dem %>%
  select(short, `Model: first choice` = wanted,
         `Named first (published)` = pref1,
         `Named first or second (published)` = pref12,
         `Offered, model` = offered) %>%
  tidyr::pivot_longer(-short, names_to = "what", values_to = "n") %>%
  mutate(what = factor(what, c("Model: first choice", "Named first (published)",
                               "Named first or second (published)", "Offered, model")))
ggplot(dem_long, aes(n, short)) +
  geom_segment(data = dem, aes(x = 0, xend = pan, y = short, yend = short),
               colour = "grey86", linewidth = 7) +
  geom_segment(data = dem, aes(x = pref1, xend = pref12, y = short, yend = short),
               colour = "#d95f02", alpha = 0.35, linewidth = 1.2) +
  geom_point(aes(colour = what, shape = what), size = 3.2) +
  geom_text(data = dem, aes(x = 0, label = sprintf("%d places ", pan)),
            hjust = 1, size = 2.9, colour = "grey40") +
  scale_colour_manual(values = c("#d95f02", "#9e6a3b", "#d95f02", "#1f3b57"), name = NULL) +
  scale_shape_manual(values = c(17, 1, 16, 15), name = NULL) +
  scale_x_continuous(expand = expansion(mult = c(0.14, 0.03))) +
  labs(x = "Children or families, 2026", y = NULL,
       title = sprintf("Counting second choices, %d of the ten schools are over-asked",
                       sum(dem$pref12 > dem$pan)),
       subtitle = "Grey bar: the admission number. Named counts: council factsheet, 2026.") +
  theme_bh() +
  theme(legend.position = "top", legend.direction = "horizontal",
        plot.title.position = "plot") +
  guides(colour = guide_legend(nrow = 2), shape = guide_legend(nrow = 2))
Figure 19: Each school’s places against how many families ask for it. The model’s demand is a first choice. The published counts show how many families named the school first, and first or second, in the 2026 round. Under the council’s equal-preference system every one of those preferences can win a place.

The model’s demand is a first choice, and a first choice is not the whole story. The council runs an equal-preference system: every school a family names is considered on the same footing, and a family is offered the highest-ranked of its schools where it qualifies. So a school can be named first by fewer families than it holds and still refuse some of them. Dorothy Stringer is the clearest case. In 2026 301 families put it first, against 330 places, and 412 more put it second. Only 264 of those who put it first were offered it: the rest of its places went to families with a higher priority who had ranked it second or third.

The model represents that in two places rather than in the demand itself. Families in the two paired catchments are treated as content with either school unless they are among the few who would take only one (3% would take only Stringer and 6% only Varndean, §8), and a family refused at one school of a pair is offered the other before anywhere else (§10). Counting those families, the model’s demand for Stringer as a first choice or as the partner of a first choice at Varndean is 530, against the 713 families who named it first or second.

The ceiling is where the model stops being about preferences and starts being about administration. It is also where most of the fit comes from: §7 showed that without it the behavioural terms explain almost nothing.

9.2 Two ceilings

The simulator carries both, and which one is in force is a setting.

The proportional ceiling (rule = "published") cuts every applicant to an oversubscribed school back by the same share. That is the average outcome of a single lottery over everyone who applied, and it is what the published model used before the council’s priorities were added.

The tiered ceiling (rule = "priorities") fills each community school’s places in the council’s own order, and runs a proportional cut only within a tier — which is what a lottery inside a priority averages to. Every number in this guide and in the strategic view uses this one.

Show code
tibble::tribble(
  ~Priority, ~`Who`, ~`Ceiling on the tier`,
  "1–3", "Looked-after children, SEN and exceptional need, siblings",
  "not separable in zone-level flows — see below",
  "4", "Children eligible for free school meals living in the catchment",
  sprintf("with 5, up to %.0f%% of the admission number", 100 * rl$fsm_cap_share),
  "5", "Children eligible for free school meals living elsewhere",
  sprintf("with 4, up to %.0f%% of the admission number", 100 * rl$fsm_cap_share),
  "6", "Children from a single-school catchment applying outside it",
  sprintf("up to %.0f%% of the admission number", 100 * rl$p6_share),
  "7", "Children living in the catchment", "whatever is left",
  "8", "Everyone else", "whatever is left after that") %>%
  knitr::kable(align = "lll")
The council’s oversubscription priorities as the model applies them at the six community schools. Academies and faith schools keep the proportional ceiling: their criteria are not in any published data.
Priority Who Ceiling on the tier
1–3 Looked-after children, SEN and exceptional need, siblings not separable in zone-level flows — see below
4 Children eligible for free school meals living in the catchment with 5, up to 30% of the admission number
5 Children eligible for free school meals living elsewhere with 4, up to 30% of the admission number
6 Children from a single-school catchment applying outside it up to 5% of the admission number
7 Children living in the catchment whatever is left
8 Everyone else whatever is left after that

A child who misses a capped tier drops to the one below: an FSM child in the catchment falls to 7, an FSM child from elsewhere to 6 if they qualify and 8 if not, a priority-6 child to 8. Nobody is removed from the queue by missing a tier.

NoteWhat the model cannot see: priorities 1 to 3

Looked-after children, children with SEN or exceptional need, and siblings cannot be told apart in flows between neighbourhoods and schools — nothing in the published data says which children have a sibling already at a school. Most siblings live in the catchment, so they sit inside tier 7, and the comparison below folds the council’s published priority 3 into tier 7 for that reason.

The tie-break inside each priority is a random draw, not distance: the council’s published admission arrangements break ties by random allocation. That is what makes a proportional cut within a tier the right average.

9.3 Who counts as FSM

The tiers need to know how many children in each neighbourhood are eligible for free school meals. The model has no pupil records, so it uses the neighbourhood’s IDACI score scaled by a single number:

\[\text{FSM share of zone } i \;=\; \min\!\left(1.34 \times \text{IDACI}_i,\; 0.95\right)\]

That multiplier is not chosen. It is solved for, so that the FSM-priority places the model gives at the three rationing schools total the 192 the council actually made in 2026. It puts 20% of the city’s Year 7 children in the FSM group. It is a calibrated quantity, not a measured one, and it inherits whatever IDACI gets wrong about who is eligible where.

9.4 Against the published outturn

The council publishes, for the three schools that actually rationed in 2026, how many offers went out under each priority. The model was fitted to the total of tiers 4 and 5 only; everything else in this chart is free.

Show code
tier_cmp %>%
  tidyr::pivot_longer(c(modelled, observed), names_to = "src", values_to = "n") %>%
  mutate(src = factor(src, c("observed", "modelled"), c("Council", "Model")),
         tier = factor(tier, c("FSM (4, 5)", "Single-school catchment (6)",
                               "In catchment, siblings included (3, 7)",
                               "Everyone else (8)"))) %>%
  ggplot(aes(n, forcats::fct_rev(tier), fill = src)) +
  geom_col(position = position_dodge(width = 0.7), width = 0.65) +
  facet_wrap(~ School, ncol = 1) +
  scale_fill_manual(values = c(Council = "#1f3b57", Model = "#d95f02"), name = NULL) +
  labs(x = "Offers", y = NULL,
       title = "The ceiling, tier by tier, against what the council did",
       subtitle = "2026 entry. The council's priorities 0 to 2 are left out; the model cannot separate them from the tiers it has") +
  theme_bh() +
  theme(legend.position = "top",
        strip.text = element_text(face = "bold", hjust = 0))
Figure 20: Offers by priority at the three schools that rationed in 2026: what the council published, and what the model produces. Only the FSM total across the three schools was calibrated; the rest is the model’s own.

The catchment tier is close at all three schools, and priority 6 is almost exact — it is capped at 5% of the admission number and the model fills the cap, as the council did. The FSM tiers are the weak point: the model gives Varndean the full 30% and the council gave it fewer, because an FSM child with a sibling at the school is admitted under priority 3 and never reaches the FSM tier at all. The three-school total is right by construction; the split between them is the model’s own, and it is too generous where siblings are most common.

54 offers across the three schools went to looked-after children, children with SEN and exceptional need. The model places those children somewhere — it just cannot tell you that this is why.

9.5 Do disadvantaged children choose differently?

The tiers above take free school meal children to have the same preferences as everyone else. ipf_priorities() built their flow as a fixed share of each neighbourhood’s total — same shortlist, same willingness to cross the city — and only the priorities differed. A September 2026 FOI gave a way to test that, because it recorded how many children each school admitted under each priority, which nothing in the model was fitted to.

The result looked damning. In catchment the model is close. Out of catchment it put 34 children into Varndean where the council admitted 5, and the city-wide total was nearly right — 95 modelled against 99 — so it was the split between schools that failed, not the volume.

That pattern is what an identical-preferences assumption would produce, so the model now carries two parameters to relax it, both no-ops by default:

Parameter What it does Default
beta_fsm distance decay for free school meal flows beta
alpha_fsm exponent on attractiveness for those flows 1

Their flow is rebuilt from the same utility with those exponents in place, and is production-constrained on its own, so every disadvantaged child in a neighbourhood still goes somewhere. Set to the defaults the two halves sum back to exactly the old flow.

9.5.1 Neither of them fixes it

Show code
tibble::tribble(
  ~Parameter,    ~Value, ~`Out-of-catchment MAE`, ~`In-catchment MAE`, ~`Varndean, modelled`,
  "as published", "—",    10.2,  4.1, 34.5,
  "beta_fsm",     "2.3",  12.2,  5.5, 28.7,
  "beta_fsm",     "3.0",  13.0,  8.2, 10.8,
  "beta_fsm",     "4.0",  15.2, 18.5,  1.2,
  "alpha_fsm",    "0.6",   9.5,  3.7, 33.8,
  "alpha_fsm",    "0.4",   9.3,  5.4, 31.2,
  "alpha_fsm",    "0.0",  10.2,  9.8, 24.5) |>
  knitr::kable(align = "llrrr", digits = 1)
Fit against the September 2026 FOI, sweeping each parameter on its own. Lower mean absolute error is better.
Parameter Value Out-of-catchment MAE In-catchment MAE Varndean, modelled
as published — 10.2 4.1 34.5
beta_fsm 2.3 12.2 5.5 28.7
beta_fsm 3.0 13.0 8.2 10.8
beta_fsm 4.0 15.2 18.5 1.2
alpha_fsm 0.6 9.5 3.7 33.8
alpha_fsm 0.4 9.3 5.4 31.2
alpha_fsm 0.0 10.2 9.8 24.5

Making disadvantaged children travel less far makes the fit worse on every measure, and monotonically. The reason is visible in the totals: the city-wide out-of-catchment figure was already right, so suppressing travel destroys a number that was correct in order to chase one that was not. By beta_fsm = 4 the in-catchment error has tripled.

Flattening the response to attractiveness does a little better — the out-of-catchment error falls from 10.2 to 9.3 — but it leaves Varndean at 31 against 5, which is the whole of the problem.

9.5.2 Why no preference parameter can close it

Because the two numbers are not counting the same children.

The council’s priority 3 is siblings, and it sits above both free school meal tiers. At Varndean it absorbed 70 children. The model has no priority 3 — §8 says so plainly, because looked-after children, SEN and siblings cannot be told apart in zone-level flows — so a disadvantaged child with an older brother or sister at the school lands in the model’s tier 4 or 5 and in the council’s priority 3. Varndean is the school where that bites hardest: long oversubscribed, and with families outside its catchment who already have a child there.

So the comparison was never like-for-like, and the out-of-catchment panel is measuring a definitional gap rather than a behavioural one. No exponent on distance or attractiveness can close it, which is what the sweep shows.

NoteWhat to do with this

Leave both parameters at their defaults. They are there so the question can be asked again — against a future FOI that reports priorities 3, 4 and 5 separately, or against pupil-level data — and the answer this time is that the model’s preference assumptions are not what is wrong.

Read the free school meal tiers as a combined total, not school by school. The three-school total is right by construction and the split between them is not, for the reason above.

Treat priority 5 at an oversubscribed school as unreliable. It is the tier most contaminated by siblings and the one with the fewest children in it.

9.6 What the ceiling does to the city

Show code
by_sch %>%
  left_join(r_pub$flows %>% group_by(name) %>%
              summarise(pub = sum(flow), .groups = "drop"), by = "name") %>%
  arrange(desc(wanted)) %>%
  transmute(School = short, Places = pan,
            `Wanted` = sprintf("%.0f", wanted),
            `Proportional ceiling` = sprintf("%.0f", pub),
            `Council's priorities` = sprintf("%.0f", offered)) %>%
  knitr::kable(align = "lrrrr")
The two ceilings compared. A school can end up fuller than its own demand: that is the re-offering of children refused elsewhere, which is the next section.
School Places Wanted Proportional ceiling Council’s priorities
Cardinal Newman 360 441 360 360
Varndean 300 421 300 300
Blatchington Mill 330 274 330 330
King’s 180 232 180 180
Dorothy Stringer 330 230 330 330
Patcham 225 191 222 215
Portslade Aldridge 220 140 150 153
Hove Park 180 128 165 168
Brighton Aldridge 180 81 91 91
Longhill 210 78 87 90

5 of the 10 city schools fill. The two at the bottom of the table are the mirror image: Longhill is offered 90 children against 210 places, and no ceiling anywhere in the city changes that.

Notice Dorothy Stringer and Blatchington Mill. Both are the first choice of fewer children than they have places for, and both finish full. The places are taken by children whose first choice was full — at Varndean, Cardinal Newman and King’s — and for whom these schools were the partner or the second preference. That is the subject of §10, and it is why the published counts above, which include second preferences, show both schools heavily asked for.

9.7 Checks you can run

Show code
tibble::tribble(
  ~Check, ~Result,
  "No school exceeds its admission number",
  sprintf("largest overshoot %.2f children", max(by_sch$offered - by_sch$pan)),
  "Every tiered school's tiers sum to its intake",
  sprintf("largest error %.2f children",
          max(abs(rowSums(tier_pan[, 2:6]) - tier_pan$intake))),
  "The FSM tiers never exceed their cap",
  sprintf("largest use %.1f%% of the admission number, at %s",
          100 * max(tier_pan$fsm_share),
          short_sch(tier_pan$name[which.max(tier_pan$fsm_share)])),
  "Priority 6 never exceeds its cap",
  sprintf("largest use %.1f%% of the admission number",
          100 * max(tier_pan$p6_share)),
  "FSM offers at the three rationing schools",
  sprintf("%.0f modelled against %d published",
          sum((r_pri$tiers$p45_in + r_pri$tiers$p45_out)[r_pri$tiers$name %in% rl$rationing]),
          sum(rl$outturn_2026$offers[rl$outturn_2026$priority %in% 4:5])),
  "Children are not lost at the ceiling",
  sprintf("%s placed with it, %s without",
          fmt_n(round(sum(r_pri$flows$flow))), fmt_n(round(sum(r_free$flows$flow))))) %>%
  knitr::kable(align = "lr")
What the ceiling should satisfy.
Check Result
No school exceeds its admission number largest overshoot 0.00 children
Every tiered school’s tiers sum to its intake largest error 0.00 children
The FSM tiers never exceed their cap largest use 30.0% of the admission number, at Varndean
Priority 6 never exceeds its cap largest use 5.0% of the admission number
FSM offers at the three rationing schools 192 modelled against 192 published
Children are not lost at the ceiling 2,267 placed with it, 2,267 without

9.8 What to be careful about

  • It is a mean, not a draw. Every number here is the average outcome of the lottery, so no scenario in this work shows the spread. The variance of a real allocation round is not modelled.
  • Admission numbers are the published ones, not the numbers a school could take. Where a school has taken over its admission number in the past, the model will not.
  • Only community schools get the tiers. The two academy trusts and the two faith schools keep the proportional cut, so their intakes carry no priority structure at all — and Cardinal Newman has the largest admission number in the city.
  • The FSM group is a neighbourhood share, not a child. A zone contributes its IDACI-scaled fraction to the FSM tiers of every school it sends children to. That is a reasonable average and a poor description of any individual.
  • Siblings are inside the catchment tier, which makes the FSM tiers too large at the schools where siblings are most numerous.

9.9 Questions this raises

  1. Would a sibling term be estimable from the published data — last year’s intake by catchment, scaled by an average family size?
  2. Should the model run the lottery rather than average it, so the uncertainty in a marginal neighbourhood’s outcome is visible?
  3. Should the academies’ and faith schools’ own criteria be modelled from their published admission policies rather than left proportional?

10 Re-offering the children who are refused

10.1 Why a proportional cut is not enough

The ceiling in §9 tells each full school how many of its applicants it can take. It does not say what happens to the rest. Left there, the production constraint of §8 would rescale each neighbourhood’s remaining flows to place its children — which spreads a child refused at Varndean across every other school in proportion to how much they wanted each one first.

That is not what happens. A family refused at Varndean who would also take Dorothy Stringer gets Dorothy Stringer. A family refused at Cardinal Newman goes where families in their part of the city put their second preference. So refused demand is re-offered explicitly, in two steps, and only ever to schools that still have room.

10.2 Step one: the partner school

In the two paired catchments — Stringer with Varndean, Hove Park with Blatchington Mill — a family who would take either school and is refused at one is offered the other before anything else is considered.

Show code
tibble(key = names(partner_ov), partner = unname(partner_ov)) %>%
  tidyr::separate(key, c("Catchment", "Refused at"), sep = " ", extra = "merge") %>%
  transmute(Catchment = catch_lab(Catchment),
            `Refused at` = short_sch(`Refused at`),
            `Offered instead` = short_sch(partner)) %>%
  knitr::kable(align = "lll")
The partner map. It follows the family’s own catchment, so it holds under any boundary design.
Catchment Refused at Offered instead
Hove Park / Blatchington Mill Hove Park Blatchington Mill
Hove Park / Blatchington Mill Blatchington Mill Hove Park
Varndean / Dorothy Stringer Dorothy Stringer Varndean
Varndean / Dorothy Stringer Varndean Dorothy Stringer

The families who would take only one of the pair are excluded from this step: they are carried as populations of their own, and a child who would not take Stringer is never sent there.

10.2.1 Where the “only one of the pair” families come from

This group matters out of all proportion to its size, because it is the only source of displacement the model produces in most years (Section 9). So it is worth being explicit that the share is bounded by the data rather than measured from it.

The council’s catchment preference table gives, for each catchment, how many children named each school at each rank. What it does not give is who named both. A catchment of 2,028 children in which 1,818 named Stringer somewhere in their three choices and 1,877 named Varndean is consistent with almost everyone naming both, or with far fewer doing so. The counts alone fix only the range:

  • the most overlap possible is the smaller of the two counts — every child who named the less popular school also named the other;
  • the least is what is forced by arithmetic, \(n_A + n_B - N\), which is zero unless the two counts together exceed the catchment;
  • independent naming, \(n_A n_B / N\), is what you would see if choosing one told you nothing about the other.

Families plainly do not choose the two independently — a pair of schools in one catchment are substitutes, so overlap should sit above independence. The model therefore takes the midpoint between maximum overlap and independence, and carries the full range with it:

Show code
cal_ex %>%
  transmute(Catchment = catch_lab(catchment),
            `Would take only` = short_sch(school),
            `Named it` = fmt_n(named),
            `Children in the catchment` = fmt_n(children),
            `If naming were independent` = fmt_pct(100 * share_independent),
            `Used` = fmt_pct(100 * share),
            `Range the counts allow` = sprintf("%s to %s",
                                               fmt_pct(100 * share_lo),
                                               fmt_pct(100 * share_hi))) %>%
  knitr::kable(align = "llrrrrr")
The share of each paired catchment taken to accept only one of its two schools. The bounds are what the counts allow; the share is the assumption inside them.
Catchment Would take only Named it Children in the catchment If naming were independent Used Range the counts allow
Hove Park / Blatchington Mill Hove Park 1,344 2,347 10% 5% 0% to 18%
Hove Park / Blatchington Mill Blatchington Mill 1,919 2,347 35% 30% 24% to 43%
Varndean / Dorothy Stringer Dorothy Stringer 1,818 2,028 7% 3% 0% to 7%
Varndean / Dorothy Stringer Varndean 1,877 2,028 10% 6% 3% to 10%

In children, for the 2026 cohort, that gives:

Show code
pop_ex %>%
  transmute(Catchment = catch_lab(catchment),
            Population = dplyr::case_when(
              pop == "either" ~ "Would take either school",
              TRUE ~ paste("Would take only", short_sch(sub("^only ", "", pop)))),
            Children = fmt_n(round(children))) %>%
  knitr::kable(align = "llr")
How each paired catchment’s children are split between the populations the model runs.
Catchment Population Children
Varndean / Dorothy Stringer Would take either school 586
Varndean / Dorothy Stringer Would take only Dorothy Stringer 22
Varndean / Dorothy Stringer Would take only Varndean 40
Hove Park / Blatchington Mill Would take either school 503
Hove Park / Blatchington Mill Would take only Blatchington Mill 230
Hove Park / Blatchington Mill Would take only Hove Park 40

Three things follow, and the third is the one to hold onto.

The populations are not symmetric. In the Hove Park and Blatchington Mill catchment, 30% are taken to want Blatchington and not Hove Park, against 5% the other way. That is the preference gap between the two schools showing up in the choice set itself, not only in the attractiveness term.

The ranges are wide, and one of them is very wide. The share who would take only Hove Park could be anywhere from 0% to 18% on these counts. The slider marked “Paired-catchment families who would take only one of the two” in the simulator exists for this: it scales all four shares, so any result that depends on them can be tested against the range rather than taken on trust.

Displacement is sensitive to it, and nothing else in the model is nearly as sensitive. A child who would take either school is almost never displaced, because the partner school absorbs them at step one. A child who would take only one is displaced whenever that one fills, however much room the partner has. That is why the strategic view and the simulator now report the two kinds of displacement separately (Section 9), and why the honest reading of a small displacement figure in a paired catchment is “this many families did not get the one school they asked for” rather than “this many children could not be accommodated near home”.

The council could replace the whole construction with a fact. The preference records it already holds say exactly how many families named one school of a pair and not the other; releases 2 and 10 in the strategic view’s list ask for it.

10.3 Step two: the second preferences of their catchment

Everything else is shared among the schools with room, in proportion to the second preferences families in that home catchment actually gave. That is the council’s catchment preference table, pooled over 3 rounds, normalised within each catchment:

Show code
kern_tbl %>%
  group_by(catchment) %>% slice_max(share, n = 3) %>% ungroup() %>%
  transmute(Catchment = catch_lab(catchment),
            School = short_sch(school),
            `Share of the catchment's second preferences` = fmt_pct(100 * share)) %>%
  knitr::kable(align = "llr")
The three schools each catchment’s families most often name second. Sixty numbers in all — six catchments by ten schools.
Catchment School Share of the catchment’s second preferences
BACA (Brighton Aldridge) Varndean 29%
BACA (Brighton Aldridge) Patcham 26%
BACA (Brighton Aldridge) Dorothy Stringer 22%
Varndean / Dorothy Stringer Dorothy Stringer 55%
Varndean / Dorothy Stringer Varndean 32%
Varndean / Dorothy Stringer Cardinal Newman 6%
Hove Park / Blatchington Mill Blatchington Mill 31%
Hove Park / Blatchington Mill Hove Park 25%
Hove Park / Blatchington Mill King’s 21%
Longhill Varndean 24%
Longhill Dorothy Stringer 21%
Longhill Longhill 17%
PACA (Portslade Aldridge) Hove Park 30%
PACA (Portslade Aldridge) King’s 27%
PACA (Portslade Aldridge) Blatchington Mill 18%
Patcham Varndean 42%
Patcham Dorothy Stringer 31%
Patcham Patcham 14%

A catchment-level table would send every neighbourhood in Longhill’s catchment the same way, so it is modulated by where the neighbourhood actually is. Each neighbourhood’s weight on school \(j\) is

\[b_{ij} \;=\; k_{h(i),j} \times \frac{u_{ij}}{\bar u_{h(i),j}}\]

where \(u_{ij}\) is the share of \(i\)’s children the model sent to \(j\) before rationing and \(\bar u\) is the average of that share across the catchment. A neighbourhood at the Patcham end of Varndean’s catchment therefore sends more of its overflow to Patcham than one at the other end, without anyone having to write that rule down.

NoteWhen a school has moved

The second preferences in the table were given with the schools where they stand today. If a run moves one — Longhill to Elm Grove — the comparison \(u/\bar u\) is made against the flows at today’s sites, so a catchment that the move brings a school nearer to sends it more of its overflow. Without that correction the kernel would be read against a city the families answering it never saw. With no move, the two are the same flow and nothing changes.

10.4 What it does in 2026

Show code
flows_plot <- A_ov %>%
  group_by(from = short_sch(from), to = short_sch(to), route) %>%
  summarise(n = sum(n), .groups = "drop") %>%
  filter(n > 0.5) %>%
  mutate(from = forcats::fct_reorder(from, n, sum),
         to = forcats::fct_reorder(to, n, sum))
ggplot(flows_plot, aes(n, from, fill = to)) +
  geom_col(width = 0.7, colour = "white", linewidth = 0.4) +
  facet_wrap(~ route, ncol = 1, scales = "free_y") +
  scale_fill_brewer(palette = "Set2", name = "Offered instead") +
  labs(x = "Children", y = "Refused at",
       title = sprintf("%.0f children are re-offered somewhere else",
                       sum(A_ov$n)),
       subtitle = sprintf("2026 entry, current catchment map. %.0f take the partner school, %.0f follow second preferences",
                          route_n("Partner school"), route_n("Second preferences"))) +
  theme_bh() +
  theme(legend.position = "right",
        strip.text = element_text(face = "bold", hjust = 0))
Figure 21: Where refused children are re-offered, over all rounds of the cascade. The partner route is the paired catchments; everything else follows the home catchment’s second preferences.

The cascade runs 3 rounds, refusing 254, then 15, then nobody. Re-offered demand can overfill a school with a little room left, so the next round cuts it back and re-offers the excess again; the totals fall away quickly because each round has less to place and fewer schools to place it in.

Two things in that chart are worth stating plainly. The largest single movement in the city is Varndean to Dorothy Stringer (76 children by both routes together), which is why Stringer finishes full despite being wanted first by fewer children than it has places for. And the schools receiving the overflow are, in order, Dorothy Stringer, Blatchington Mill, Hove Park — the schools with room, not the schools nearest the refusal.

10.5 This is where displacement comes from

The strategic view’s displacement measure is the difference between the two quantities this section connects: what a neighbourhood’s children wanted, and what they were offered. A child who is counted as displaced is a child who appears somewhere in the chart above.

Show code
d_ai %>%
  mutate(lost = pmax(wanted - flow, 0)) %>%
  group_by(zone) %>%
  summarise(Oi = first(Oi), catchment = first(catchment),
            lost = sum(lost), .groups = "drop") %>%
  slice_max(lost, n = 6) %>%
  transmute(Neighbourhood = sub("[|].*$", "", zone),
            Catchment = catch_lab(catchment),
            `Children` = sprintf("%.0f", Oi),
            `Refused their first choice` = sprintf("%.0f", lost),
            `Share` = fmt_pct(100 * lost / Oi)) %>%
  knitr::kable(align = "llrrr")
The neighbourhoods losing most children to the ceiling, 2026. Displacement is demand that was refused, wherever it ended up.
Neighbourhood Catchment Children Refused their first choice Share
E01016865 Varndean / Dorothy Stringer 24 5 22%
E01016901 Varndean / Dorothy Stringer 30 4 14%
E01016868 Varndean / Dorothy Stringer 19 4 21%
E01016866 Varndean / Dorothy Stringer 19 4 20%
E01016875 Hove Park / Blatchington Mill 20 4 18%
E01016882 Hove Park / Blatchington Mill 21 4 17%

10.6 Checks you can run

Show code
tibble::tribble(
  ~Check, ~Result,
  "This section's cascade reproduces the engine's flows",
  if (ov_gap == 0) "exactly, to the last decimal place" else
    sprintf("largest difference %.0e children", ov_gap),
  "Rounds to convergence",
  sprintf("%d, refusing %s", length(ov_rounds),
          paste(sprintf("%.0f", ov_rounds), collapse = " then ")),
  "Everything refused is re-offered",
  sprintf("%.0f refused over all rounds, %.0f re-offered",
          sum(ov_rounds), sum(A_ov$n)),
  "Re-offers only go to schools with room",
  sprintf("largest final intake above an admission number: %.2f children",
          max(by_sch$offered - by_sch$pan)),
  "The partner route is only for families who would take both",
  sprintf("%.0f children, all of them in the two paired catchments",
          route_n("Partner school")),
  "Nobody is left unplaced",
  sprintf("%s children in, %s children out",
          fmt_n(round(sum(d_ai$Oi_pop[!duplicated(d_ai$orig)]))),
          fmt_n(round(sum(d_ai$flow))))) %>%
  knitr::kable(align = "lr")
What the re-offering should satisfy. The first row is the important one: the cascade above is run independently of the engine and lands in the same place.
Check Result
This section’s cascade reproduces the engine’s flows exactly, to the last decimal place
Rounds to convergence 3, refusing 254 then 15 then 0
Everything refused is re-offered 270 refused over all rounds, 270 re-offered
Re-offers only go to schools with room largest final intake above an admission number: 0.00 children
The partner route is only for families who would take both 60 children, all of them in the two paired catchments
Nobody is left unplaced 2,267 children in, 2,267 children out

10.7 What to be careful about

  • The kernel is fixed, and it is pre-2024. The second preferences come from the catchment table the council published for the old boundaries. Under a redrawn map the model still uses them, modulated by proximity but not re-estimated.
  • Second preferences are not a preference for a school with room. The table records what families named when the school they wanted was still possible. A family whose second choice is also full is handled by the next round, which is a reasonable mechanism but not an observed one.
  • Overflow stays in the city. The kernel has entries for the ten city schools only, so a refused child is never re-offered a school outside Brighton and Hove — although out-of-city schools are in the choice set at the first stage (§8) and take 51 children in 2026 that way.
  • It is demand, not children. Every quantity here is a fraction of a neighbourhood’s cohort, so “68 children take the partner school” means 68 children’s worth of demand spread over many neighbourhoods.
  • Refusal has no cost. A family refused twice is no less likely to be placed than one refused once, and nothing in the model records how far down their own list a child ends up.

10.8 Questions this raises

  1. Should the kernel be re-estimated for a redrawn map, or is the proximity modulation enough?
  2. Should third preferences enter, rather than the cascade re-using second preferences in every round?
  3. Should a refused child be allowed out of the city, as they can be at the first stage?

11 The algorithm, end to end

Sections 2 to 10 take the model apart a term at a time. This one puts it back together in the order the code actually executes it, and shows where each of the simulator’s controls attaches — because every control does its work by changing an input to one of these steps and making the whole sequence run again.

flowchart TB
    s1["<b>1 · Cohort</b><br/>O_i scaled to the entry year (§2)<br/>⚙ Entry year"]
    s2["<b>2 · Destinations</b><br/>the open schools, and the cost table<br/>for Longhill's site (§4)<br/>⚙ Close or open a school · Longhill's site"]
    s3["<b>3 · Attractiveness</b><br/>fitted W_j × the slider (§3)<br/>⚙ Attractiveness, per school"]
    s4["<b>4 · Competition</b><br/>C_j from where the schools stand (§7)<br/>↻ attractiveness · site · open or close"]
    s5["<b>5 · Catchment indicator</b><br/>from the map in force (§6)<br/>⚙ Catchment map"]
    s6["<b>6 · Populations</b><br/>families who would take only one of a pair (§8)<br/>⚙ Paired-catchment exclusivity"]
    s7["<b>7 · Pull</b><br/>W_j · c_ij^−β · e^γ · C_j^δ (§5–§7)<br/>⚙ How much the catchment counts<br/>↻ built twice, once per population, if<br/>disadvantaged flows carry their own<br/>β or α (§9.5) — off by default"]
    s8["<b>8 · Balance</b><br/>A_i, so every row places its children (§8)<br/>↻ once per population when 7 splits"]
    s9["<b>9 · Wanted</b><br/>T_ij before any ceiling"]
    d1{"<b>10 · Any school over<br/>its admission number?</b><br/>⚙ Places, per school"}
    s10["<b>11 · Ceiling</b><br/>council tiers at community schools,<br/>proportional elsewhere (§9)<br/>⚙ When a school is full<br/>↻ catchment map · places"]
    s11["<b>12 · Re-offer the refused</b><br/>partner school, then the catchment's<br/>second preferences (§10)<br/>↻ catchment map · site"]
    s12["<b>13 · Flows</b><br/>T_ij as offered"]
    o1["<b>What comes out</b><br/>intakes, fill and displacement<br/>journeys and the deprived-journey gap<br/>disadvantaged mix and segregation (§12)<br/>funding and reserves (§13)"]

  s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9 --> d1
  d1 -- "yes" --> s10 --> s11 --> d1
  d1 -- "no, or no school has room" --> s12 --> o1

  classDef ctl fill:#fdecd9,stroke:#d95f02,stroke-width:2px,color:#3d1c00;
  classDef moved fill:#eef4fb,stroke:#d95f02,stroke-width:1.5px,stroke-dasharray:5 3,color:#102a43;
  classDef run fill:#eef4fb,stroke:#1f3b57,stroke-width:1px,color:#102a43;
  classDef out fill:#edf6ef,stroke:#2b7a4b,stroke-width:1px,color:#102a43;
  class s1,s2,s3,s5,s6,s7,d1,s10 ctl;
  class s4,s11 moved;
  class s8,s9,s12 run;
  class o1 out;

One run of the model, top to bottom. ⚙ marks a simulator control and the step it sets; those steps are outlined in solid orange. ↻ marks a step that has no control of its own but is recomputed when another control moves; those are outlined in dashed orange. Moving any control re-runs everything below the step it touches.

11.1 What each control changes, and what it makes the model redo

Show code
tibble::tribble(
  ~Control, ~`What it sets`, ~`Recomputed`,
  "Entry year",
  "the cohort index that scales every zone's $O_i$ (§2.7)",
  "everything: the rows change size, so the ceiling binds differently",
  "Catchment map",
  "which catchment each zone and each school belongs to (§6)",
  "the catchment indicator, the priority-6 rule, and which school is a family's partner in the cascade",
  "Longhill's site",
  "which of the two routed cost tables is used, and where Longhill stands (§4, §7)",
  "every journey to Longhill, its competition term, and the reference flows the overflow kernel is read against",
  "Attractiveness, per school",
  "a multiplier on the fitted $W_j$ (§3.6)",
  "that school's pull everywhere, and every *other* school's competition term",
  "Close or open a school",
  "the set of destinations, including CoMArt (§8)",
  "every neighbourhood's choice set and balancing factor, and the competition term city-wide",
  "Paired-catchment exclusivity",
  "a multiplier on the share of families who would take only one of a pair (§8)",
  "how many origin-populations there are and what each can be offered",
  "How much the catchment counts",
  "a multiplier on every fitted $\\gamma_h$ together (§6.6)",
  "the pull of a catchment school for the families in it",
  "Places, per school",
  "the admission number used as the ceiling (§9)",
  "who is refused and therefore everything the cascade does",
  "When a school is full",
  "proportional ceiling or the council's tiers, the priority-6 share, and whether the FSM priority applies (§9)",
  "the ceiling and the cascade, not the demand that goes into them") %>%
  knitr::kable(align = "lll")
Every control in the simulator, the step it attaches to, and what has to be recomputed when it moves.
Control What it sets Recomputed
Entry year the cohort index that scales every zone’s \(O_i\) (§2.7) everything: the rows change size, so the ceiling binds differently
Catchment map which catchment each zone and each school belongs to (§6) the catchment indicator, the priority-6 rule, and which school is a family’s partner in the cascade
Longhill’s site which of the two routed cost tables is used, and where Longhill stands (§4, §7) every journey to Longhill, its competition term, and the reference flows the overflow kernel is read against
Attractiveness, per school a multiplier on the fitted \(W_j\) (§3.6) that school’s pull everywhere, and every other school’s competition term
Close or open a school the set of destinations, including CoMArt (§8) every neighbourhood’s choice set and balancing factor, and the competition term city-wide
Paired-catchment exclusivity a multiplier on the share of families who would take only one of a pair (§8) how many origin-populations there are and what each can be offered
How much the catchment counts a multiplier on every fitted \(\gamma_h\) together (§6.6) the pull of a catchment school for the families in it
Places, per school the admission number used as the ceiling (§9) who is refused and therefore everything the cascade does
When a school is full proportional ceiling or the council’s tiers, the priority-6 share, and whether the FSM priority applies (§9) the ceiling and the cascade, not the demand that goes into them

11.2 What a control never changes

This is the important half of the picture. No control in the simulator moves a fitted parameter. The things estimated in §3 and §5 to §7 — the balanced attractiveness, \(\beta\), each catchment’s \(\gamma_h\), \(\delta\) and \(\sigma\) — are fixed when the input bundle is built, and a run reads them. So does the second-preference kernel (§10), the disadvantage pull \(\lambda_j\) (§12), and the two routed cost tables, which are far too slow to build inside a run. The same goes for beta_fsm and alpha_fsm (§9.5): they live in the bundle, not on a slider, and at their defaults step 7 produces a single pull, exactly as drawn.

That division is what the sliders mean. The catchment slider does not re-estimate how much families follow their catchment; it multiplies the estimate by a number you choose, and the honest reading of any slider position other than 1 is “what if this were different”, never “this is what it would be”.

11.3 Where the loops are

Both are in the rationing: one loop, and a fixed sequence inside it.

The outer loop is the one drawn above: cut every over-subscribed school back to its admission number, re-offer the children refused, which may overfill a school that had a little room, so cut again. In 2026 it takes 3 passes (254, then 15 children refused, then none). It stops when nothing is refused or no school has room left; children who cannot be placed anywhere stay unplaced rather than being forced over an admission number.

The sequence inside it is the tier cascade within a single pass: a child who misses a capped tier at a community school drops into the next tier down (§9), in a fixed order rather than by iterating, and the two groups — FSM-eligible and everyone else — are carried through it separately so each places its own children.

Where that split begins depends on the bundle. By default the two groups share a pull and only part company at the ceiling, so the FSM-eligible flow is a fixed share of each origin’s total and ipf_priorities() takes it from fsm_share. Give disadvantaged children their own \(\beta\) or \(\alpha\) (§9.5) and they part company at step 7 instead: two pulls, two balancing terms, and the built flow handed to ipf_priorities() directly, because once the populations have different preferences the split can no longer be recovered from the total. Either way both halves reach the cascade, and the ceiling treats them exactly as drawn.

A third loop, outside the model, sits in the disadvantage layer (§12): it shares each neighbourhood’s disadvantaged children over the flows it has already produced, spilling any excess where a school would otherwise be sent more disadvantaged children than children.

11.4 Why it recomputes rather than updates

Everything above takes about 0.05 seconds for the model and another 0.04 for all of the outcome measures, on 5,824 zone-school rows. That is fast enough that the simulator does not attempt to update anything incrementally: every change to any control re-runs the whole sequence from step 1. Nothing is cached between runs, so nothing can be stale, and two runs with identical settings are identical to the last decimal place.

It also means the comparisons the simulator shows — this scenario against the baseline, or a school’s fill now against 2030 — are two complete runs compared afterwards, not one run adjusted.

12 Who is disadvantaged, and where they go

Everything up to here places children. The strategic view’s integration measures need to know which children: how the city’s disadvantaged Year 7 cohort is spread across the ten schools, how far those children travel, and what a scenario does to both. That is a layer on top of the model, and this section sets it out because every segregation number in this work depends on it.

12.1 The measure that was not good enough

The first version of this work measured a school’s intake by the neighbourhoods its children came from: sum the flows, weight by each neighbourhood’s deprivation, done. It is the natural thing to do with a spatial interaction model, and it is wrong.

Show code
dis_fit %>%
  select(short, Neighbourhood = neighbourhood, Published = target) %>%
  tidyr::pivot_longer(-short, names_to = "what", values_to = "share") %>%
  mutate(what = factor(what, c("Neighbourhood", "Published")),
         short = forcats::fct_reorder(short, share, max)) %>%
  ggplot(aes(share, short, group = short)) +
  geom_line(colour = "grey70", linewidth = 0.8) +
  geom_point(aes(colour = what), size = 3) +
  scale_x_continuous(labels = scales::percent) +
  scale_colour_manual(values = c(Neighbourhood = "#d95f02", Published = "#1f3b57"),
                      name = NULL,
                      labels = c("Measured by the neighbourhoods children come from",
                                 "What the school actually has")) +
  labs(x = NULL, y = NULL,
       title = "Within a mixed neighbourhood, families do not choose alike",
       subtitle = "Disadvantaged share of the intake, ten city schools") +
  theme_bh() + theme(legend.position = "top", legend.direction = "vertical",
                     legend.justification = "left")
Figure 22: Each school’s disadvantaged intake share: what the neighbourhood measure gives, and what the school actually has. The neighbourhood measure is not a little noisy — it puts the schools in the wrong order.

Longhill is the clearest case: the neighbourhoods its children come from are 22% disadvantaged, and the school is 49%. Its catchment contains Rottingdean and Ovingdean as well as Whitehawk, and the two do not send their children to the same school. A measure that cannot see inside a neighbourhood cannot see that, and a segregation index built on it is measuring residential segregation with a school label attached.

12.2 So the children are placed explicitly

Two ingredients, and one rule.

Each neighbourhood has a disadvantaged share. As in §9, it is the IDACI score scaled by a single constant, capped at 95%:

\[\text{disadvantaged share of zone } i \;=\; \min\!\left(1.76 \times \text{IDACI}_i,\; 0.95\right)\]

Each school has a pull. \(\lambda_j\) is how much more, or less, likely a disadvantaged child is to end up at school \(j\) than the rest of the children in the same flow. It is one number per school, and it is fitted.

The rule. Within each origin-population, the neighbourhood’s disadvantaged children are shared over the schools that neighbourhood’s children attend, in proportion to \(T_{ij}\lambda_j\), and never more than \(T_{ij}\) itself — a school cannot be sent more disadvantaged children than it is sent children. Where that cap binds, the excess spills to the other schools in the row and the sharing repeats.

ImportantThe flows themselves are never touched

This layer labels children inside flows the model has already produced; it does not move anybody. Disadvantage plays no part in where the model sends children, in the ceiling, or in the re-offering. That is a deliberate separation — the places are decided by preference, distance and admission rules, and the disadvantage measure then reads off who is in them — but it does mean the model cannot represent a family’s circumstances changing what they ask for.

12.3 What \(\lambda\) is fitted to

Show code
dis_fit %>%
  arrange(desc(target)) %>%
  transmute(School = short,
            `Whole school, published` = fmt_pct(100 * published),
            `Year 7, 2026` = ifelse(is.na(year7_fsm), "—", fmt_pct(100 * year7_fsm)),
            `Fitted to` = fmt_pct(100 * target),
            `Modelled` = fmt_pct(100 * modelled),
            `$\\lambda_j$` = sprintf("%.2f", lambda)) %>%
  knitr::kable(align = "lrrrrr")
The target for each school, and the pull that reproduces it. Community schools are fitted to their September 2026 Year 7 free-school-meal offers; the academies and the faith schools have no published Year 7 figure, so they are fitted to their whole-school disadvantaged share.
School Whole school, published Year 7, 2026 Fitted to Modelled \(\lambda_j\)
Longhill 34% 49% 49% 49% 4.53
Brighton Aldridge 48% — 48% 48% 0.88
Hove Park 32% 35% 35% 35% 1.33
Varndean 23% 30% 30% 30% 0.93
Portslade Aldridge 28% — 28% 28% 0.72
Patcham 22% 27% 27% 27% 1.14
Dorothy Stringer 19% 25% 25% 25% 0.85
Blatchington Mill 17% 22% 22% 22% 0.98
Cardinal Newman 17% — 17% 17% 0.60
King’s 16% — 16% 16% 0.49

Three things in that table need saying.

The Year 7 figures are higher than the whole-school ones at the community schools. That is the free-school-meal admission priority working: it began with the September 2025 round, so a school’s current Year 7 is more disadvantaged than its Years 8 to 11. Fitting to the whole-school figure would have calibrated the model to the policy’s absence. This is the lag correction, and it is the reason the targets are mixed.

The academies and faith schools are fitted to the wrong vintage, for want of a right one. No Year 7 breakdown is published for them. Their targets are the Department for Education’s three-year mean, so if their Year 7 intakes have moved as the community schools’ have, the model will not know.

Longhill’s \(\lambda\) is extreme. 4.5 against about 1 for a school taking its fair share. It has to be: the school is 49% disadvantaged on an intake of only 90 children drawn from neighbourhoods that are, on the model’s own numbers, much less disadvantaged than that. A large \(\lambda\) on a small intake is a fragile thing, and it is the single number the segregation results are most sensitive to.

12.3.1 Why it is extreme: Longhill was fitted across a boundary change

There is a reason this one number is so far from the others, and it is not entirely behavioural. Longhill’s catchment changed between the rounds the model is fitted to and the round it is calibrated on.

Until the 2026 entry year, Whitehawk was in Longhill’s catchment. The council consulted from October 2024, determined the new arrangements in March 2025, and they governed applications made in the autumn of 2025 — so September 2026 was the first intake under the map that moved Whitehawk out. That is the round \(\lambda\) is fitted to.

The model does represent the change. On the pre-2024 map it sends 17 of Whitehawk’s children to Longhill; on today’s map, 4. Geography alone puts Longhill at 30% disadvantaged under the old map and 25% under today’s. What it was actually offered in September 2026 was 49%.

So the pull has to be enormous to close a gap that the boundary change opened. Fitted three ways, the same school gives:

Show code
tibble::tibble(
  `Fitted to` = c("The published three-year share, on the pre-2024 map",
                  "The published three-year share, on today's map",
                  "The September 2026 Year 7 offers, on today's map (adopted)"),
  Target = c(fmt_pct(100 * dfl$published), fmt_pct(100 * dfl$published),
             fmt_pct(100 * dfl$target)),
  `$\\lambda$` = sprintf("%.2f", c(dfl$lambda_pre, dfl$lambda_today_map, dfl$lambda))) %>%
  knitr::kable(align = "lrr")
Longhill’s fitted pull on disadvantaged children, against three different targets and maps.
Fitted to Target \(\lambda\)
The published three-year share, on the pre-2024 map 34% 1.31
The published three-year share, on today’s map 34% 1.89
The September 2026 Year 7 offers, on today’s map (adopted) 49% 4.53

Read that table slowly. Under the old map, with the old target, Longhill’s pull was 1.31 — barely above its fair share. Geography did nearly all the work, because Whitehawk was in the catchment. Take Whitehawk out and fit to the first cohort admitted without it, and the pull has to do the work instead.

The awkward fact is that the first intake under the new map was the most disadvantaged on record. If Whitehawk’s families had simply followed the boundary, Longhill’s share should have fallen. It rose. Longhill is well under its admission number, so a family living outside its catchment who names it is admitted anyway — and on these figures a good many still did. The catchment term suppresses that demand harder than the first year’s offers suggest it should, and \(\lambda\) quietly makes up the difference.

12.3.2 Catchment memory

So the model carries an explicit assumption about it. A share of the moved neighbourhoods — 50% of them — is treated as still choosing the old school: Longhill gains the catchment term for those families, and Dorothy Stringer and Varndean lose it. The share fades by 20% a year from 2026, on the reasoning that the households who remember Longhill as their school leave the system as their cohorts do.

It has to work on both sides. Handing Whitehawk a Longhill bonus while it keeps the Dorothy Stringer and Varndean bonus it gained in 2026 leaves it with two catchment schools and changes almost nothing, because the schools it gained are the far more attractive ones. And it touches demand only: these children are out of catchment for the admission priorities, which is what they legally are. That asymmetry is the point. They can still name the school, and at a school under its admission number naming it is enough.

Show code
tibble::tibble(
  `Families still choosing Longhill` = c("None", fmt_pct(100 * CM$share), "All"),
  `Children it sends there` = sprintf("%.0f", c(cm_0$to_lh, cm_half$to_lh, cm_1$to_lh)),
  `Longhill's intake` = sprintf("%.0f", c(cm_0$intake, cm_half$intake, cm_1$intake)),
  `Disadvantaged` = c(fmt_pct(100 * cm_0$dep), fmt_pct(100 * cm_half$dep),
                      fmt_pct(100 * cm_1$dep)),
  `Segregation index` = sprintf("%.3f", c(cm_0$gorard, cm_half$gorard, cm_1$gorard))) %>%
  knitr::kable(align = "lrrrr")
What the assumption does, 2026. The neighbourhoods hold about 78 children between them.
Families still choosing Longhill Children it sends there Longhill’s intake Disadvantaged Segregation index
None 0 88 47% 0.125
50% 4 90 49% 0.117
All 17 103 56% 0.126

The share is an assumption, not a measurement. Nothing published identifies it. What published data does say is that zero is not tenable: a neighbourhood that had followed the boundary would not leave Longhill with the most disadvantaged intake it has ever taken. The figure the model uses is a half, fading; the table shows what the alternatives would do.

Two consequences are worth stating plainly. The assumption is doing real work — it takes Longhill’s fitted pull from 5.93 to 4.53, which is the difference between a number that looks like a behavioural finding and one that looks like a correction. And it has a shelf life: by 2035 the share is down to 7%, so the model expects Longhill’s intake to become less disadvantaged over the decade even with nothing else changing.

This is the single assumption in the model that the council could retire outright. Section 9 of the strategic view asks for allocations by home neighbourhood. That release would replace this half with a measured number, for this boundary change and for any other.

There is a second reading worth holding alongside the first, and it points the other way. Longhill’s intake has collapsed to 90 children. The families still choosing it may be disproportionately deprived whatever the map says, because the others have the means and the inclination to go elsewhere. That is sorting, and it would persist. Nothing here separates the two, and only one thing will: the September 2027 and 2028 factsheets. If Longhill’s free school meal share falls back towards a third, the boundary explanation was right and \(\lambda\) is overstated. If it stays near a half, the sorting explanation was right and \(\lambda\) is measuring something real.

Show code
dis_fit %>%
  mutate(short = forcats::fct_reorder(short, lambda),
         side = lambda >= 1) %>%
  ggplot(aes(lambda, short, fill = side)) +
  geom_col(width = 0.7) +
  geom_vline(xintercept = 1, colour = "grey40", linewidth = 0.5) +
  geom_text(aes(label = sprintf("%.2f", lambda)), hjust = -0.15,
            size = 3.1, colour = "grey30") +
  scale_fill_manual(values = c(`FALSE` = "#1f3b57", `TRUE` = "#d95f02"), guide = "none") +
  scale_x_continuous(expand = expansion(mult = c(0.01, 0.12))) +
  labs(x = "λ", y = NULL,
       title = "Two schools take far more than their flows would suggest",
       subtitle = "Fitted pull on disadvantaged children, 2026, today's map and admission rules") +
  theme_bh()
Figure 23: The fitted pull on disadvantaged children, by school. Above 1 means a disadvantaged child in a given flow is likelier than their neighbours to end up there; below 1, less likely.

12.4 What comes out of it

Show code
tibble::tribble(
  ~Measure, ~Value, ~`What it is`,
  "Gorard segregation index", sprintf("%.3f", o_now$gorard),
  "half the sum of each school's share of the city's disadvantaged children less its share of all of them; 0 is an even spread",
  "Most and least disadvantaged intake",
  sprintf("%s and %s", fmt_pct(100 * o_now$intake_hi), fmt_pct(100 * o_now$intake_lo)),
  "the ends of the same distribution",
  "Deprived-journey gap", sprintf("%+.1f minutes", o_now$dep_gap),
  "how much longer a disadvantaged child's journey is than everyone else's",
  "City disadvantaged share", fmt_pct(100 * dis_city),
  "the model's own cohort, from the scaled IDACI scores") %>%
  knitr::kable(align = "lll")
The integration measures the strategic view reports, for the baseline run.
Measure Value What it is
Gorard segregation index 0.117 half the sum of each school’s share of the city’s disadvantaged children less its share of all of them; 0 is an even spread
Most and least disadvantaged intake 49% and 16% the ends of the same distribution
Deprived-journey gap +3.4 minutes how much longer a disadvantaged child’s journey is than everyone else’s
City disadvantaged share 27% the model’s own cohort, from the scaled IDACI scores

Every scenario in the strategic view is scored on the first and the third of those. A boundary redraw, a closure, a change to admission priorities: what moves is which schools the flows go to, and this layer turns that into a segregation number.

WarningThis measure has changed sign more than once

The direction of a headline finding — whether moving Longhill’s catchment out of Whitehawk raises or lowers segregation — has changed three times during this work, and every change came from a correction to measurement, not to the scenario:

  1. on the neighbourhood measure, it lowered segregation;
  2. on the whole-school fit against today’s map, it raised it;
  3. fitted on the map in force before the change, it was flat;
  4. on Year 7 offers with the corrected neighbourhood weighting, it raises it again.

The scenario never moved. That is a warning about how much weight a single segregation index can carry, and it is why the strategic view reports the direction with the calibration it depends on stated alongside.

12.5 Checks you can run

Show code
dis_now <- dis_flows(inp, r_ai$flows)
dis_expected <- r_ai$flows %>%
  distinct(orig, .keep_all = TRUE) %>%
  mutate(share = pmin(dp$k * idaci_score, 0.95)) %>%
  summarise(n = sum(share * Oi_pop)) %>% pull(n)
tibble::tribble(
  ~Check, ~Result,
  "Every school reproduces its target share",
  sprintf("largest error %.3f percentage points",
          100 * max(abs(dis_fit$modelled - dis_fit$target))),
  "No flow carries more disadvantaged children than children",
  sprintf("largest excess %.1e children", max(dis_now - r_ai$flows$flow)),
  "The city's disadvantaged children are all placed",
  sprintf("%s placed against %s in the cohort",
          fmt_n(round(sum(dis_now))), fmt_n(round(dis_expected))),
  "The scaling differs from the admission-priority one, and should",
  sprintf("%.2f × IDACI here against %.2f × IDACI for the FSM tiers (§9)",
          dp$k, rl$fsm_takeup),
  "The pull is held fixed while scenarios change",
  "yes: fitted once on today's map and rules, then never re-fitted") %>%
  knitr::kable(align = "lr")
What the disadvantage layer should satisfy.
Check Result
Every school reproduces its target share largest error 0.009 percentage points
No flow carries more disadvantaged children than children largest excess 0.0e+00 children
The city’s disadvantaged children are all placed 602 placed against 602 in the cohort
The scaling differs from the admission-priority one, and should 1.76 × IDACI here against 1.34 × IDACI for the FSM tiers (§9)
The pull is held fixed while scenarios change yes: fitted once on today’s map and rules, then never re-fitted

12.6 What to be careful about

  • Two different disadvantage scalings live in this model, and they are not a mistake. §9’s 1.34 × IDACI is the group with current free-school-meal eligibility, because that is who the admission priority applies to. This section’s 1.76 × IDACI is the broader disadvantaged group — free school meals at any point in six years, plus looked-after children — because that is what the published school-level figures count. They answer different questions and are calibrated to different targets.
  • \(\lambda\) is a summary of everything not modelled. Sibling patterns, transport cost, appeals, the confidence to name an over-subscribed school first: all of it is inside one number per school.
  • It is fitted on one year. A single admissions round, on the current map, under the current rules.
  • A higher index is not a measured harm. The Gorard index describes how unevenly disadvantaged children are spread, which is what the council’s policy aim is framed around. It says nothing about their results. The national evidence in How to Pull the Right Lever is that disadvantaged pupils do slightly better, not worse, in schools with more of them once absence and prior attainment are allowed for, and the strategic view sets out what that means for the options (the strategic view).
  • A scenario’s segregation number is only as good as \(\lambda\). The model holds the pull fixed while it moves boundaries and schools, which assumes a school’s relationship with disadvantaged families travels with it. For a relocation like Elm Grove that is a strong assumption and it is impossible to test from published data.

12.7 Questions this raises

  1. Should \(\lambda\) be modelled rather than fitted — as a function of distance, catchment and school characteristics — so it can respond to a scenario instead of being carried through it?
  2. Should the disadvantage layer feed back into the flows, so that a family’s circumstances change what they ask for as well as what they get?
  3. Is the Gorard index the right summary, given how much of the movement above came from re-measurement rather than from the system changing?

13 From intakes to money

The strategic view’s money section argues that the city’s schools are already spending their reserves and that small schools are the most exposed. The simulator turns that into a number for every run: given the intakes a configuration produces, where does each school’s budget land? This section is the arithmetic behind that number, and it is deliberately simple — simple enough to be done by hand for any one school.

13.1 What each school starts from

Show code
fin %>%
  arrange(short) %>%
  transmute(School = short,
            `Funded roll` = fmt_n(funded_roll),
            `Pupil-led rate` = sprintf("£%s", fmt_n(marginal_pp)),
            `Fixed funding` = gbp_k(fixed),
            Income = sprintf("£%.1fm", income / 1e6),
            `In-year balance` = gbp_k(balance),
            Reserve = gbp_k(reserve),
            `Accounts year` = year_label) %>%
  knitr::kable(align = "lrrrrrrl")
What the money calculation starts from. Rolls and funding rates are from the DfE’s school-level funding statistics for 2025–26; income, reserve and in-year balance are each school’s latest published return.
School Funded roll Pupil-led rate Fixed funding Income In-year balance Reserve Accounts year
Blatchington Mill 1,626 £6,570 £428k £12.4m −£246k £492k 2024-25
Brighton Aldridge 700 £7,889 £152k £7.2m £383k £1k 2023-24
Cardinal Newman 1,851 £6,707 £236k £17.6m −£1,740k −£1,777k 2024-25
Dorothy Stringer 1,643 £6,744 £566k £13.2m £37k £831k 2024-25
Hove Park 949 £7,331 £527k £9.6m −£546k −£1,052k 2024-25
King’s 867 £6,548 £152k £6.5m £795k £1,011k 2023-24
Longhill 719 £7,440 £342k £7.5m −£168k £531k 2024-25
Patcham 1,107 £6,675 £444k £9.0m £371k −£82k 2024-25
Portslade Aldridge 950 £7,026 £152k £7.7m £791k £534k 2023-24
Varndean 1,493 £6,643 £511k £12.2m −£151k £662k 2024-25
  • The funded roll is the pupil count the school’s 2025–26 schools block allocation was calculated on. The schools block funds Years 7 to 11 only, so a sixth form is not in it.
  • The pupil-led rate is what one more pupil in Years 7 to 11 brings in. It is the school’s whole allocation less the parts that do not depend on pupil numbers — the lump sum, and where they apply, split site, rates, PFI and exceptional factors — divided by the funded roll. Those fixed parts are why a small school receives more per pupil and loses less per pupil than the average suggests.
  • Income, reserve and in-year balance come from each school’s own accounts: the Consistent Financial Reporting return for maintained schools (to 31 March) and the Academies Accounts Return for the three academies (to 31 August). The academies’ latest published year is therefore a year behind the others, which the table shows rather than hides.

13.2 The calculation

For each school, in a run:

\[\text{roll}_j = 5 \times \text{intake}_j\]

\[\Delta\text{funding}_j = (\text{roll}_j - \text{funded roll}_j) \times \text{pupil-led rate}_j\]

\[\text{annual balance}_j = \text{in-year balance}_j + \Delta\text{funding}_j \times (1 - s)\]

\[\text{years of reserve}_j = \frac{\text{reserve}_j}{-\,\text{annual balance}_j}\quad\text{when the balance is negative}\]

Three choices are built into those four lines.

The roll is a steady state. Five year groups at this run’s intake is the roll the school converges on if the configuration holds for five years — not the roll next September. A school whose intake falls today feels a fifth of the loss next year and all of it in year five.

Today’s in-year balance is carried forward. A school that ran a deficit in its latest accounts is assumed to keep running it, before any change in pupils. That is the least-assumption starting point, and it means a single unusual year in a school’s accounts carries straight through.

\(s\) is how much of the lost funding the school can take out of its costs. A school that loses £100k of funding and cuts £75k of spending ends £25k worse off. The simulator uses \(s = 0.75\), the middle of the three assumptions in the strategic view (90%, 75% and 50%). It is symmetric: a school that gains pupils is assumed to spend three quarters of the new money.

13.3 Worked example: Longhill

Longhill’s funded roll is 719. The model gives it an intake of 90 in 2026, so the steady-state roll is 451 — 268 fewer pupils. At its pupil-led rate of £7,440 that is −£1,997k of funding a year. Taking out three quarters of the cost leaves −£499k, which added to its latest in-year balance of −£168k gives an annual position of −£668k, a deficit of 9% of its income. Its reserve of £531k would last about 0.8 years.

13.4 Where every school lands

Show code
by_school_path %>%
  filter(year %in% c(min(yrs), max(yrs))) %>%
  mutate(year = factor(year),
         short = forcats::fct_reorder(short, gap_pct, min)) %>%
  ggplot(aes(gap_pct, short)) +
  geom_vline(xintercept = 0, colour = "grey45") +
  geom_line(aes(group = short), colour = "grey75", linewidth = 1) +
  geom_point(aes(colour = year), size = 3.2) +
  scale_x_continuous(labels = scales::percent) +
  scale_colour_manual(values = setNames(c("#1f3b57", "#d95f02"),
                                        c(min(yrs), max(yrs))),
                      name = "Intake year") +
  labs(x = "Annual surplus or deficit, share of income", y = NULL,
       title = sprintf("%d schools in deficit on the 2026 intake, %d on the 2035 one",
                       o26$in_deficit,
                       money_path$in_deficit[money_path$year == max(yrs) & money_path$shed == SHED]),
       subtitle = sprintf("Steady-state roll; %.0f%% of lost funding taken out of costs", 100 * SHED)) +
  theme_bh() + theme(legend.position = "top")
Figure 24: Each school’s annual position at the steady-state roll, as a share of its income, for the 2026 intake and for the 2035 one. The difference between the two points is the falling cohort alone: nothing else in the run changes. Where only one point shows, the school fills in both years and its position does not move.

The ordering is not the ordering of intakes. King’s gains pupils against its funded roll and starts from a surplus; Cardinal Newman fills completely and is still the deepest in deficit, because its latest accounts already show a deficit of £1,740k a year. The model’s intakes decide the change; the accounts decide the starting point, and for several schools the starting point is most of the story.

13.5 Over time, and under the cost assumption

Show code
money_path %>%
  mutate(shed = factor(sprintf("%.0f%% of lost funding cut from costs", 100 * shed),
                       levels = sprintf("%.0f%% of lost funding cut from costs",
                                        100 * c(0.9, 0.75, 0.5)))) %>%
  ggplot(aes(year, city_gap / 1e6, colour = shed)) +
  geom_hline(yintercept = 0, colour = "grey45") +
  geom_line(linewidth = 1.1) +
  geom_point(size = 1.8) +
  scale_colour_manual(values = c("#9aa5b1", "#1f3b57", "#d95f02"), name = NULL) +
  scale_y_continuous(labels = function(x) ifelse(x < 0, sprintf("−£%.0fm", -x), sprintf("£%.0fm", x))) +
  scale_x_continuous(breaks = yrs[yrs %% 2 == 0]) +
  labs(x = "Intake year on the slider", y = "All ten schools, per year",
       title = "The falling cohort turns into money only as fast as costs fail to follow it",
       subtitle = "Steady-state roll at each year's modelled intake, council priorities, today's map") +
  theme_bh() + theme(legend.position = "top", legend.direction = "vertical",
                     legend.justification = "left")
Figure 25: The city’s combined annual position for each year on the simulator’s slider, under the strategic view’s three assumptions about how much lost funding schools can take out of their costs. The simulator uses the middle line.

If every pound lost were a pound of cost cut (\(s = 1\)), the cohort decline would cost nothing and the city’s position would simply be the sum of today’s balances, −£474k. Even at 90% the city’s schools end 2035 at −£2,540k a year. At the simulator’s setting it is −£5,640k, and −£10,806k if schools can only cut half of what they lose. The assumption moves the answer more than most of the policy levers do, which is why the app states it on its money chart.

13.6 The model’s error, in pounds

The money table is only as good as the intakes behind it, and the closing section (§15) shows where the model’s intakes are wrong. Replacing the modelled 2026 intakes with the offers the council actually made isolates that:

Show code
m_obs %>%
  mutate(diff = gap - gap_obs) %>%
  arrange(desc(abs(diff))) %>%
  transmute(School = short,
            `Modelled intake` = sprintf("%.0f", intake),
            `Actual offers` = offers,
            `Position, modelled` = gbp_k(gap),
            `Position, actual` = gbp_k(gap_obs),
            `Difference` = gbp_k(diff)) %>%
  knitr::kable(align = "lrrrrr")
Each school’s annual position on the modelled 2026 intake and on the council’s actual 2026 offers, same arithmetic otherwise.
School Modelled intake Actual offers Position, modelled Position, actual Difference
Brighton Aldridge 91 146 −£104k £442k −£546k
Hove Park 168 136 −£750k −£1,039k £289k
Portslade Aldridge 153 183 £463k £730k −£266k
Patcham 215 204 £319k £226k £93k
Longhill 90 81 −£668k −£752k £85k
King’s 180 180 £849k £849k £0k
Varndean 300 300 −£139k −£139k £0k
Dorothy Stringer 330 330 £49k £49k £0k
Blatchington Mill 330 330 −£207k −£207k £0k
Cardinal Newman 360 360 −£1,826k −£1,826k £0k

The two Aldridge academies look worse in the simulator than their real 2026 intakes justify, and Hove Park better. 1 school moves between deficit and surplus because of it (Brighton Aldridge), but anyone reading one school’s row should read it with this table beside it. The open model’s own finance table avoids the problem by rebasing to the published offers and using the model only for the change over time; the simulator cannot, because its whole purpose is to show intakes that have not happened.

13.7 Checks you can run

Show code
tibble::tribble(
  ~Check, ~Result,
  "With every lost pound cut from costs, a school's position is its latest balance",
  sprintf("%s at s = 1, for every school",
          ifelse(isTRUE(all.equal(outcomes(inp, r_ai, shed = 1)$by_school$gap,
                                  outcomes(inp, r_ai, shed = 1)$by_school$balance)),
                 "yes:", "no:")),
  "The steady-state roll is five year groups",
  sprintf("%s pupils across the city against %s funded",
          fmt_n(round(sum(m26$roll_ss))), fmt_n(sum(fin$funded_roll))),
  "The pupil-led rate excludes fixed funding",
  sprintf("£%s to £%s per pupil; fixed funding £%sk to £%sk",
          fmt_n(min(fin$marginal_pp)), fmt_n(max(fin$marginal_pp)),
          fmt_n(min(fin$fixed) / 1e3), fmt_n(max(fin$fixed) / 1e3)),
  "Schools already overdrawn",
  paste(fin$short[fin$reserve <= 0], collapse = ", "),
  "Overdrawn and in deficit in this run — the ones a council acts on first",
  sprintf("%d: %s", o26$critical,
          paste(m26$short[m26$reserve <= 0 & m26$gap < 0], collapse = ", "))) %>%
  knitr::kable(align = "lr")
What the money calculation should satisfy.
Check Result
With every lost pound cut from costs, a school’s position is its latest balance yes: at s = 1, for every school
The steady-state roll is five year groups 11,080 pupils across the city against 11,905 funded
The pupil-led rate excludes fixed funding £6,548 to £7,889 per pupil; fixed funding £152k to £566k
Schools already overdrawn Hove Park, Patcham, Cardinal Newman
Overdrawn and in deficit in this run — the ones a council acts on first 2: Hove Park, Cardinal Newman

13.8 What to be careful about

  • It is a steady state, not a budget. No pay award, no inflation, no energy prices, no capital receipts, no change in the funding formula. All in today’s money.
  • One year of accounts is the baseline. A school with one bad year carries it forward indefinitely; so does a school with one good one.
  • Sixth forms are outside it. Four schools have one. Their income includes post-16 funding, so a deficit as a share of income is flattered for them, and nothing in the model moves their sixth-form rolls.
  • The same cost assumption for everyone. A school with 91% of its income in staff costs cannot shed cost as easily as one at 68%, and the model treats them alike.
  • Patcham is overdrawn but in surplus. Its reserve is negative and its in-year balance positive, so it counts as having no years of reserve left while recovering. The app says “overdrawn” rather than printing a number that would mean the opposite.

13.9 Questions this raises

  1. Should the cost assumption vary by school, with staff share as the obvious first candidate?
  2. Should the roll move year by year, as a cohort model, rather than jumping to its steady state?
  3. Should the simulator rebase each school to its actual offers, as the open model does, and show only the change a scenario makes?

14 Scenarios: which runs, and how to read them

Everything so far has been one run of the model. The strategic view’s conclusions come from comparing many. This section says which runs those are, how they were chosen, what each is scored on, and — most importantly — how to read the difference between two of them.

14.1 What a scenario is

A scenario is a set of control settings and nothing more: a catchment map, a site for Longhill, some admission numbers, perhaps an attractiveness multiplier or a change to the admission rules, and a year. It is applied on top of the baseline, the model is run from step 1 (§11), and the outcomes are read off. No scenario changes a fitted parameter, and no scenario is a separate model.

Scenarios live in two places.

  • The simulator’s presets — 14 starting points, each a question rather than an answer, which a user then moves the controls away from.
  • The strategic view’s option set — 29 options in 7 groups, each run in 2026, 2030, 2035, which is 87 runs in all. Every recommendation in the strategic view rests on this table.

14.2 How the option set was chosen

The rule was levers the council actually holds: the admission numbers of the six community schools, the catchment map, Longhill’s site, and the oversubscription priorities. Options that need a decision from a school the council does not run are kept in a group of their own, so a reader can see at once which results the council could deliver by itself.

Show code
co_runs %>%
  filter(year == min(year)) %>%
  group_by(group) %>%
  summarise(n = n(), options = paste(short, collapse = "; "), .groups = "drop") %>%
  arrange(group) %>%
  transmute(Group = as.character(group), Options = n, `What they are` = options) %>%
  knitr::kable(align = "lrl")
The strategic view’s option set, by group.
Group Options What they are
Baseline 1 Today
Keep Longhill open 10 Longhill 150; Longhill 120; Longhill 150, 1.5× as wanted; Central schools trimmed; Central schools trimmed hard; Whitehawk back to Longhill; Whitehawk back, Longhill 150; Whitehawk back, central trims; Pre-2024 catchments; Longhill to Elm Grove, map redrawn
Access and segregation 6 Power-diagram catchments; Power diagram, Longhill 150; No priority 6; Priority 6 at 20%; An equal chance for all; CoMArt reopened
Fit the system 2 Community schools shrunk; Every school shrunk
Asks of schools the council does not run 3 Cardinal Newman 300; Cardinal Newman 270, King’s 150; Two academies cut
Packages 6 Package A; Package B; Package C; Package D; Package E; Package F
Counterfactual 1 Longhill closed

Three of those groups need a word.

  • Packages combine single levers, because the questions the council faces are not one lever at a time. Each package is named for what it adds to an earlier one, so their differences can be traced.
  • “Longhill closed” is a counterfactual, not a recommendation. It is there so every other option can be read against the alternative the council is actually weighing.
  • “Fit the system” cuts places to the 2035 cohort plus a margin (1,920 places in all). It answers how much of the empty-place problem is simply too many places, whatever else is done.

14.3 What every run is scored on

Each run is scored on every priority at once, so no option can look good by being measured on only the thing it was designed to fix.

Show code
tibble::tribble(
  ~Priority, ~Measures, ~`Where they come from`,
  "Keeping Longhill open",
  "Longhill's intake and fill; its annual position and years of reserve",
  "§9, §10, §13",
  "A viable system",
  "empty places in the city; schools below their admission number; community schools in deficit and their combined position; the whole city's position",
  "§9, §13",
  "Access and segregation",
  "Gorard index; the deprived-journey gap; mean and 90th-percentile journeys; share of journeys over 40 minutes",
  "§4, §12",
  "Children near home",
  "children displaced from their first choice; share at a school in their own catchment; children leaving the city; the same share for Longhill's catchment alone",
  "§8, §10") %>%
  knitr::kable(align = "lll")
The measures recorded for every run, by the priority they speak to.
Priority Measures Where they come from
Keeping Longhill open Longhill’s intake and fill; its annual position and years of reserve §9, §10, §13
A viable system empty places in the city; schools below their admission number; community schools in deficit and their combined position; the whole city’s position §9, §13
Access and segregation Gorard index; the deprived-journey gap; mean and 90th-percentile journeys; share of journeys over 40 minutes §4, §12
Children near home children displaced from their first choice; share at a school in their own catchment; children leaving the city; the same share for Longhill’s catchment alone §8, §10
Show code
board_measures <- c(
  lh_intake = "Longhill's intake",
  empty = "Empty places, city",
  gorard = "Gorard segregation index",
  dep_gap = "Deprived-journey gap (min)",
  displaced = "Children displaced",
  in_catchment = "In own catchment (%)")
board <- co_runs %>%
  filter(year == 2030) %>%
  mutate(in_catchment = 100 * in_catchment) %>%
  select(id, short, group, all_of(names(board_measures))) %>%
  tidyr::pivot_longer(all_of(names(board_measures)), names_to = "m", values_to = "v") %>%
  mutate(m = factor(unname(board_measures[m]), board_measures),
         short = factor(short, rev(unique(short[order(group)]))))
ref <- board %>% filter(id == "today") %>% select(m, ref = v)
ggplot(board, aes(v, short, colour = group)) +
  geom_vline(data = ref, aes(xintercept = ref), colour = "grey60") +
  geom_point(size = 2.4, na.rm = TRUE) +
  facet_wrap(~ m, ncol = 3, scales = "free_x") +
  scale_colour_brewer(palette = "Dark2", name = NULL) +
  labs(x = NULL, y = NULL,
       title = "Most options move most measures very little",
       subtitle = "2030 entry, council priorities, each option against today's configuration") +
  theme_bh(11) +
  theme(legend.position = "bottom",
        strip.text = element_text(face = "bold", hjust = 0),
        panel.spacing.x = unit(2.2, "lines")) +
  guides(colour = guide_legend(nrow = 3))
Figure 26: The whole option set in 2030, on six of the measures. The grey line in each panel is today’s configuration in the same year, so a dot to one side of it is what the option changes. Colour is the option’s group.

14.4 How to read a scenario

Seven rules, each learned the hard way during this work.

1. Compare in the same year. The cohort falls by about a fifth between 2026 and 2035, and that alone changes every measure. An option in 2035 is read against today’s configuration in 2035 — the do-nothing case — never against today’s numbers in 2026. The grey line in the chart above is always the same-year baseline.

2. A ceiling that does not bind does nothing. Cutting Longhill to 150 or to 120 leaves every measure except empty places exactly where it was in 2030, because the model gives Longhill 71 children and neither number is reached: empty places fall from 583 to 523 and 493, and nothing else moves. An admission number only matters to the schools that fill.

3. Read the measure the option was designed to change, then all the others. Trimming the central schools (Stringer to 300, Varndean to 270, Blatchington Mill to 300, Longhill to 150) was meant to send children to Longhill. In 2030 Longhill’s intake under the option is 70.9 against 70.5 today — it barely moves. What the option does instead is cut 150 empty places and roughly double the children displaced, from 3 to 5. Refused children go to the schools with room that their catchment names second (§10), and Longhill is rarely that school.

4. Small differences in the segregation index are not findings. The Gorard index runs from 0.097 to 0.150 across the whole option set in 2030. §12 showed that re-measuring the same scenario moved it by about 0.01 and changed its sign. A difference smaller than that between two options should be read as “no clear difference”.

5. Know which results rest on held-fixed parameters. Every option keeps the fitted attractiveness, catchment terms and disadvantage pulls (§11). That is safe for an admission-number change and much less safe for a relocation or a reopening, where a school’s relationship with its families plainly changes. Moving Longhill to Elm Grove takes the deprived-journey gap from 3.9 to 0.4 minutes — a result about journeys, which the model measures directly — and the Gorard index from 0.109 to 0.142, a result that depends on Longhill’s fitted pull on disadvantaged children travelling with it to a new site.

6. A slider away from 1 is a question, not a forecast. “Longhill 1.5 times as wanted” does not say anything will make it so; it says what would follow if something did. Package B is Package A with that assumption added, and should be read as “Package A, if it also works”.

7. 2035 is an extrapolation, and the money is a steady state. The cohort projection runs out before 2035 and is extended (§2.7), and every money figure is the position a school converges on if the configuration is held (§13). The 2035 column is the direction of travel, not a plan.

14.5 A worked comparison

Show code
ids <- c("today", "whitehawk", "elm", "package_f", "close_lh")
co_runs %>%
  filter(year == 2030, id %in% ids) %>%
  mutate(id = factor(id, ids)) %>% arrange(id) %>%
  transmute(Option = short,
            `Longhill intake` = ifelse(is.na(lh_intake), "—", sprintf("%.0f", lh_intake)),
            `Empty places` = fmt_n(round(empty)),
            `Community schools in deficit` = comm_in_deficit,
            `City position` = sprintf("−£%.1fm", -city_gap / 1e6),
            Gorard = sprintf("%.3f", gorard),
            `Deprived-journey gap` = sprintf("%.1f min", dep_gap),
            Displaced = sprintf("%.0f", displaced)) %>%
  knitr::kable(align = "lrrrrrrr")
Four options against today’s configuration in 2030, on the measures the strategic view’s recommendation turns on.
Option Longhill intake Empty places Community schools in deficit City position Gorard Deprived-journey gap Displaced
Today 71 583 6 −£4.5m 0.109 3.9 min 3
Whitehawk back to Longhill 85 584 6 −£4.5m 0.111 3.5 min 0
Longhill to Elm Grove, map redrawn 86 528 6 −£4.5m 0.142 0.4 min 3
Package F 91 408 6 −£4.4m 0.137 0.5 min 3
Longhill closed — 387 4 −£3.1m 0.097 4.5 min 3

Read with the rules above:

  • Whitehawk back to Longhill raises Longhill’s intake by 14 children and cuts displacement to 0, and raises the Gorard index by 0.002 — about the size of the re-measurement effect in §12, so the segregation direction is the least secure part of the result.
  • Elm Grove — alone, or inside Package F — is the only change that nearly closes the deprived-journey gap. That is a journey result and it is the most robust thing in the table. Its segregation figure is the least robust (rule 5).
  • Package F adds the asks of the academies and faith schools to the move. Its gains over Elm Grove alone are in empty places, and they depend on decisions the council cannot make (the group rule).
  • Longhill closed has the fewest community schools in deficit and the lowest Gorard index — and the longest journeys of the five. It is on the table so the others can be judged against it, not because the model recommends it.

14.6 The simulator’s presets

Show code
tibble(Preset = names(inp$presets),
       `What it sets` = vapply(inp$presets, function(p) p$note, "")) %>%
  knitr::kable(align = "ll")
The starting points offered in the simulator. Each is a set of overrides on Today; the user moves the controls from there.
Preset What it sets
Today The city as it stands: today’s admission numbers, today’s catchments, Longhill at Ovingdean, and the council’s 2026/27 admission priorities.
Do nothing until 2035 Everything held as it is, with the cohort ten years smaller. This is the do-nothing case the rest of the document describes.
Longhill to Elm Grove, PAN 150 The relocation, at the reduced admission number, behind the catchments drawn for it.
Families choose on value added The same city, with families reading what a school adds to its pupils rather than the headline score it publishes. Attractiveness is rebuilt from the four-year value added measure in How to Pull the Right Lever, applied as strongly as the headline score is followed now. Nothing else changes: same places, same catchments, same rules. It is the one lever here that costs nothing and needs no consultation - the council writes the admissions guide.
Hove Park on one site Hove Park teaches Year 7 at the Valley Campus on Hangleton Way, 1.65 km west of the Nevill Road site its address gives. The council has given notice to close the Valley Campus on 31 August 2028. This runs the first year of the consolidated school; switch Hove Park’s Year 7 site to the Valley Campus to see the same year as it would have been. The total barely moves. Where the children come from does: at Hangleton Way the school draws far more of Hangleton & Knoll and Portslade, and much less of Westdene, Hove Park ward and Poets’ Corner.
Redraw for balance The power diagram, which spreads disadvantage most evenly of the designs tested, with everything else as it is.
Shrink the system to fit Total places cut to 1,920, about 5% above the 2035 cohort of 1,835, and shared out in proportion to today’s admission numbers. Move the total or any school from there.
Make the catchment count Everything as it is, but living in a catchment weighs far more heavily on the choice than families currently behave as though it does. This is the other way to fill a school, and it needs nothing from the school itself.
Make Longhill wanted Longhill’s attractiveness lifted to Dorothy Stringer’s, everything else unchanged. The question is what it takes, and who loses the children.
Everyone has the same chance Today’s city with no oversubscription priorities: every applicant to a full school has the same chance, which is the model section 7 of the strategic view publishes. Compare the Catchments and Fairness tabs with Today, which runs the council’s 2026/27 priorities.
The council’s first proposal: 20% open The open-admissions priority as the council first consulted on it, at 20% of places rather than the 5% it settled on after objections from the six community schools. Watch displacement in the two dual catchments.
The 2027/28 rules: Targeted FSM The 2027/28 arrangements: the FSM priority narrowed to Targeted FSM, which the council puts at about 56% of currently eligible children. Everything else as in 2026/27.
CoMArt re-opened: three small eastern schools CoMArt, closed in 2005, open again on its East Brighton site with 150 places, sharing Longhill’s catchment, and Brighton Aldridge and Longhill both cut to 150. Watch segregation, journeys, and whether the central schools still fill.
CoMArt re-opened, and Stringer to 300 The same three small eastern schools, with Dorothy Stringer’s admission number cut from 330 to 300. Does Stringer still fill, and where do the children go?

14.7 Checks you can run

Show code
nb <- co_runs %>% filter(year == 2030, id %in% c("today", "lh150", "lh120")) %>%
  select(-id, -group, -label, -short, -year, -empty, -lh_pan, -lh_fill, -below_pan,
         -comm_empty)
tibble::tribble(
  ~Check, ~Result,
  "Every option is run in every year",
  sprintf("%d options × %d years = %d runs", n_distinct(co_runs$id),
          n_distinct(co_runs$year), nrow(co_runs)),
  "The stored baseline is the simulator's own Today",
  sprintf("Gorard %.4f stored, %.4f from a fresh run", base_of(2030, "gorard"), chk_today$gorard),
  "A non-binding admission number changes nothing but capacity",
  ifelse(all(vapply(nb, function(x) diff(range(x, na.rm = TRUE)) < 1e-6, TRUE)),
         "yes: Longhill 150 and 120 match today on every other measure in 2030",
         "no: they differ"),
  "The counterfactual is flagged as one",
  sprintf("\"%s\" is in the %s group", SHORT_OPT[["close_lh"]],
          as.character(co_runs$group[co_runs$id == "close_lh"][1]))) %>%
  knitr::kable(align = "lr")
What the option set should satisfy.
Check Result
Every option is run in every year 29 options × 3 years = 87 runs
The stored baseline is the simulator’s own Today Gorard 0.1091 stored, 0.1091 from a fresh run
A non-binding admission number changes nothing but capacity yes: Longhill 150 and 120 match today on every other measure in 2030
The counterfactual is flagged as one “Longhill closed” is in the Counterfactual group

14.8 What to be careful about

  • The option set is a choice. It covers the levers the council holds and some it does not, but it is not every configuration of them. Something good may lie between two rows.
  • Every run is a mean. The lottery is averaged (§9), so no option shows how much a real round could differ from its row.
  • The measures are not weighted. The chart and the tables show every measure side by side on purpose. The strategic view weighs them, and says how; this guide does not.
  • Options are run on today’s fitted behaviour. A scenario that changes how families see a school — a new building, a new head, a relocation — changes the thing the model holds fixed.

14.9 Questions this raises

  1. Should the option set be searched rather than listed — every combination of admission numbers the council could set, scored and filtered?
  2. Should each option carry an uncertainty band from the lottery and from the fitted parameters, so rule 4 can be applied by the reader rather than by judgement?
  3. Should the relocation and reopening options be re-run with the disadvantage pull and attractiveness allowed to vary over a plausible range, since those are the results that lean on them most?

15 The matrix, filled in

15.1 The example from §1, with numbers in it

Section 1 set out the shape of the model for four neighbourhoods and four schools and left the interior empty. Every term since has filled in one part of it. Here it is complete, for 2026:

Show code
mk <- function(z, s) {
  r <- end_cells %>% filter(zone == z, name == s)
  if (!nrow(r)) return("—")
  sprintf("%.1f (%.1f)", r$wanted, r$offered)
}
data.frame(
  Neighbourhood = c(sub("[|].*$", "", end_z), "$W_j$"),
  `Dorothy Stringer` = c(unname(vapply(end_z, mk, "", s = end_s[1])), sprintf("%.2f", W5[[end_s[1]]])),
  Varndean = c(unname(vapply(end_z, mk, "", s = end_s[2])), sprintf("%.2f", W5[[end_s[2]]])),
  Longhill = c(unname(vapply(end_z, mk, "", s = end_s[3])), sprintf("%.2f", W5[[end_s[3]]])),
  `Cardinal Newman` = c(unname(vapply(end_z, mk, "", s = end_s[4])), sprintf("%.2f", W5[[end_s[4]]])),
  `$A_i$` = c(sprintf("%.2f", end_A$A[match(end_z, end_A$zone)]), ""),
  `$O_i$ (2026)` = c(sprintf("%.1f", ex_zones$Oi), ""),
  check.names = FALSE) %>%
  knitr::kable(align = "lccccrr")
The four largest neighbourhoods and four of the schools, as the model leaves them. Each cell is children wanting that school, with what they are offered after the ceiling in brackets. The rows do not sum to \(O_i\) here because six other destinations are off the right-hand side of the table.
Neighbourhood Dorothy Stringer Varndean Longhill Cardinal Newman \(A_i\) \(O_i\) (2026)
E01016901 7.6 (11.5) 20.7 (16.7) 0.0 (0.0) 0.8 (0.6) 2.58 29.8
E01016880 0.2 (0.2) 0.3 (0.1) 0.0 (0.0) 2.0 (1.6) 7.37 27.1
E01016979 0.4 (0.4) 0.5 (0.1) 0.0 (0.0) 4.5 (3.7) 11.75 25.5
E01016997 0.4 (0.4) 0.9 (0.0) 0.0 (0.0) 4.3 (3.5) 18.64 24.3
\(W_j\) 0.55 1.03 0.10 3.27

Read one row and the whole model is in it. The neighbourhood’s children (\(O_i\), §2) are divided between schools in proportion to each school’s pull — its attractiveness (\(W_j\), §3) discounted by the journey (\(c_{ij}\), §4; \(\beta\), §5), multiplied up if the neighbourhood is in its catchment (\(\gamma_h\), §6) and nudged by how crowded it is (\(C_j\), \(\delta\), §7) — with \(A_i\) (§8) set so the row places every child. Then the admission numbers bind (§9) and the children who are refused are re-offered somewhere else (§10), which is why the two numbers in a cell differ.

15.2 Does it reproduce the year it was built for?

Show code
ggplot(val, aes(observed, model)) +
  geom_abline(slope = 1, intercept = 0, colour = "grey65", linewidth = 0.5) +
  geom_point(size = 3, colour = "#1f3b57") +
  ggrepel::geom_text_repel(aes(label = short), size = 3.2, seed = 4,
                           colour = "#1f3b57", box.padding = 0.4) +
  coord_equal(xlim = c(0, 380), ylim = c(0, 380)) +
  labs(x = "Offers made by the council", y = "Modelled intake",
       title = sprintf("R² = %.2f across the ten schools", val_r2),
       subtitle = sprintf("2026 entry. Typical error %.0f children; largest %.0f, at %s",
                          val_mae, max(abs(val$gap)),
                          val$short[which.max(abs(val$gap))])) +
  theme_bh()
Figure 27: Modelled intake against the offers the council actually made for September 2026, one point per school. The line is where the model would be exactly right.

The five schools that fill are exact by construction — they are at their admission numbers in both. The information is in the five that do not, and there the model has a pattern to its errors:

Show code
val %>% filter(abs(gap) > 0.5) %>% arrange(gap) %>%
  transmute(School = short, `Council's offers` = observed,
            `Model` = sprintf("%.0f", model),
            `Difference` = sprintf("%+.0f", gap)) %>%
  knitr::kable(align = "lrrr")
The schools that do not fill, where all of the error lives.
School Council’s offers Model Difference
Brighton Aldridge 146 91 -55
Portslade Aldridge 183 153 -30
Longhill 81 90 +9
Patcham 204 215 +11
Hove Park 136 168 +32

The model sends too few children to the two Aldridge academies and too many to Hove Park and Patcham. That is one story, not four: the model has no term for a school’s reputation changing, and these are the schools whose intakes have moved most in recent rounds. §3’s note on time is the place that gap is discussed.

15.3 Every quantity in one table

Show code
p <- inp$params
tibble::tribble(
  ~Quantity, ~Value, ~`Where it comes from`, ~Section,
  "$O_i$", sprintf("%s children in 2026", fmt_n(round(sum(inp$zones$Oi)))),
  "ONS postcode directory, census age structure, council projection", "§2",
  "$W_j$", sprintf("%.2f to %.2f", min(W5), max(W5)),
  "fitted: balanced to each school's share of first preferences", "§3",
  "$c_{ij}$", sprintf("%.0f to %.0f minutes", min(inp$cost$now$cij), max(inp$cost$now$cij)),
  "routed with r5r over OpenStreetMap and the published timetables", "§4",
  "$\\beta$", sprintf("%.1f", p$beta),
  "fixed by hand from the literature, checked by sweep", "§5",
  "$\\gamma_h$", sprintf("%.1f to %.1f", min(p$gamma), max(p$gamma)),
  "fitted: Poisson deviance against the catchment preference table", "§6",
  "$\\sigma$, $\\delta$", sprintf("%.0f, %+.1f", p$sigma, p$delta),
  "$\\sigma$ fixed at 1; $\\delta$ fitted by grid search on the offers", "§7",
  "$A_i$", sprintf("%s origin-populations", fmt_n(n_distinct(d_ai$orig))),
  "derived: whatever makes each row sum to its children", "§8",
  "Exclusivity shares", sprintf("%.0f%% to %.0f%%", 100 * min(p$exclusive$share),
                                100 * max(p$exclusive$share)),
  "fitted in the paired catchments, from the same preference table", "§8",
  "Admission numbers", sprintf("%s places", fmt_n(sum(sch_city$pan, na.rm = TRUE))),
  "published by the council for 2026 entry", "§9",
  "FSM take-up", sprintf("%.2f × IDACI", rl$fsm_takeup),
  "solved so the FSM offers match the published outturn", "§9",
  "Priority caps", sprintf("%.0f%% FSM, %.0f%% priority 6",
                           100 * rl$fsm_cap_share, 100 * rl$p6_share),
  "the council's published admission arrangements", "§9",
  "Overflow kernel", sprintf("%d shares", length(p$overflow)),
  "second preferences by home catchment, from the same table", "§10",
  "Disadvantage pull $\\lambda_j$", sprintf("%.2f to %.2f", min(inp$params$dis$lambda), max(inp$params$dis$lambda)),
  "fitted so each school reproduces its published disadvantaged intake", "§12",
  "Cost shed when funding falls", sprintf("%.0f%%", 100 * formals(outcomes)$shed),
  "set: the middle of the strategic view's three assumptions", "§13",
  "Pupil-led funding rate", sprintf("£%s to £%s", fmt_n(min(inp$finance$marginal_pp)), fmt_n(max(inp$finance$marginal_pp))),
  "DfE school-level funding statistics, 2025–26", "§13",
  "Out-of-city schools", sprintf("%d, decay %.1f on straight-line km",
                                 length(p$outside$W), p$outside$decay),
  "fitted to the children the city loses to East Sussex", "§8") %>%
  knitr::kable(align = "llll")
The whole model: what is fitted, what is fixed by hand, and what is read from published data.
Quantity Value Where it comes from Section
\(O_i\) 2,267 children in 2026 ONS postcode directory, census age structure, council projection §2
\(W_j\) 0.10 to 3.27 fitted: balanced to each school’s share of first preferences §3
\(c_{ij}\) 3 to 473 minutes routed with r5r over OpenStreetMap and the published timetables §4
\(\beta\) 1.7 fixed by hand from the literature, checked by sweep §5
\(\gamma_h\) 1.0 to 2.6 fitted: Poisson deviance against the catchment preference table §6
\(\sigma\), \(\delta\) 1, +0.3 \(\sigma\) fixed at 1; \(\delta\) fitted by grid search on the offers §7
\(A_i\) 433 origin-populations derived: whatever makes each row sum to its children §8
Exclusivity shares 3% to 30% fitted in the paired catchments, from the same preference table §8
Admission numbers 2,515 places published by the council for 2026 entry §9
FSM take-up 1.34 × IDACI solved so the FSM offers match the published outturn §9
Priority caps 30% FSM, 5% priority 6 the council’s published admission arrangements §9
Overflow kernel 60 shares second preferences by home catchment, from the same table §10
Disadvantage pull \(\lambda_j\) 0.49 to 4.53 fitted so each school reproduces its published disadvantaged intake §12
Cost shed when funding falls 75% set: the middle of the strategic view’s three assumptions §13
Pupil-led funding rate £6,548 to £7,889 DfE school-level funding statistics, 2025–26 §13
Out-of-city schools 4, decay 8.0 on straight-line km fitted to the children the city loses to East Sussex §8

Four things in that table are fitted to observed behaviour (\(W_j\), \(\gamma_h\), \(\delta\), the exclusivity shares), one is fixed by hand (\(\beta\)), one is solved to match a published total (FSM take-up), and everything else is either measured or published. That is the honest division, and it is worth carrying into any reading of a scenario: the further a scenario moves from the city as it is, the more weight falls on the four fitted quantities and the less on the published ones.

15.4 If a number looks wrong

The symptom Where to look first
A neighbourhood has too many or too few children §2: the zone’s postcode count and its split across catchments
A school is more or less wanted than expected §3: its fitted \(W_j\), and whether the slider is at 1
A journey looks implausible §4: the walk cap and the distance fallback
Everyone goes to their nearest school §5: \(\beta\), and §6: the catchment term
A school fills that should not §10: what is being re-offered to it, and from where
The city total does not match the council’s §2.6: the projection and the state-sector share
An out-of-city school takes too many §8: the decay on straight-line kilometres
The FSM tiers look too large §9: siblings sit inside the catchment tier

15.5 Following a number back

Every figure in the strategic view comes from one of the runs described in §14, which is the model of §2 to §10 with the disadvantage layer of §12 and the money of §13 read off the result. It should now be possible to follow any number back through these sections to a published source.