---
title: "Brighton and Hove School Allocations"
author: "Adam Dennett"
---
```{r setup, include=FALSE}
knitr::opts_chunk$set(echo = FALSE, message = FALSE, warning = FALSE)
library(dplyr)
library(tidyr)
library(knitr)
library(DT)
library(ggplot2)
library(plotly)
library(wesanderson)
library(crosstalk)
library(htmltools)
# Helper to split "N (M)" cells into two numeric columns.
split_nm <- function(df, col) {
new_offer <- paste0(col, "_offer")
df[[col]] <- as.character(df[[col]])
df %>%
separate(!!col, into = c(col, new_offer), sep = " \\(", convert = FALSE) %>%
mutate(
!!col := suppressWarnings(as.numeric(gsub(",", "", trimws(.data[[col]])))),
!!new_offer := suppressWarnings(as.numeric(gsub("[)*]", "", .data[[new_offer]])))
)
}
# Apply split_nm across the standard preference + total columns.
parse_prefs <- function(df, cols) {
for (col in cols) df <- split_nm(df, col)
df
}
dt_opts <- list(
pageLength = 15,
dom = 'Bfrtip',
buttons = c('copy', 'csv', 'excel', 'pdf', 'print'),
scrollX = TRUE
)
```
# Secondary Schools — Year 7 Allocations
Data transcribed from the annual Brighton & Hove City Council Year 7 Allocation Factsheets. Each cell shows `preferences received (offers made)`. From 2026 onwards the council collects a 4th preference alongside the traditional 1st / 2nd / 3rd. The 2010 booklet reported preferences received but broke down offers only by admission priority (SEN / looked-after / sibling / catchment / out-of-catchment), not by preference rank — so per-preference offer counts for 2010 are `NA`. The 2011 factsheet could not be located.
Click a year to view that factsheet.
::: panel-tabset
## 2026
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-factsheet-year-7-places-september-2026>
```{r}
yr_7_admissions_2026 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("259 (244)", "128 (128)", "498 (352)", "301 (264)", "103 (103)", "218 (163)",
"72 (72)", "158 (158)", "163 (163)", "370 (274)", "2270 (1921)"),
No_2nd_pref = c("267 (61)", "48 (9)", "221 (8)", "412 (59)", "171 (19)", "262 (16)",
"31 (8)", "77 (23)", "79 (19)", "464 (23)", "2032 (245)"),
No_3rd_pref = c("232 (23)", "68 (6)", "305 (0)", "238 (4)", "206 (8)", "216 (1)",
"30 (0)", "224 (17)", "59 (1)", "214 (3)", "1792 (63)"),
No_4th_pref = c("151 (2)", "106 (3)", "197 (0)", "144 (3)", "249 (6)", "158 (0)",
"18 (1)", "164 (6)", "59 (0)", "72 (0)", "1318 (21)"),
Total = c("910 (330)", "350 (146)", "1221 (360)", "1095 (330)", "729 (136)", "855 (180)",
"151 (81)", "623 (204)", "360 (183)", "1120 (300)", "7414 (2250)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "No_4th_pref", "Total"))
datatable(yr_7_admissions_2026, extensions = 'Buttons', options = dt_opts)
```
2026 is the first year that the council reports 4 preferences rather than 3.
## 2025
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-factsheet-year-7-places-september-2025>
```{r}
yr_7_admissions_2025 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("258 (244)", "83 (83)", "523 (349)", "215 (186)", "130 (130)", "252 (170)",
"74 (74)", "166 (166)", "175 (175)", "427 (284)", "2303 (1861)"),
No_2nd_pref = c("273 (61)", "37 (5)", "239 (11)", "479 (127)", "242 (27)", "280 (10)",
"39 (17)", "74 (18)", "87 (12)", "390 (13)", "2140 (301)"),
No_3rd_pref = c("303 (25)", "33 (1)", "319 (0)", "228 (17)", "274 (14)", "219 (0)",
"36 (1)", "235 (18)", "70 (3)", "192 (3)", "1911 (87)"),
Total = c("834 (330)", "153 (89)", "1081 (360)", "922 (330)", "646 (171)", "751 (180)",
"151 (97)", "475 (202)", "332 (190)", "1009 (300)", "6354 (2249)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2025, extensions = 'Buttons', options = dt_opts)
```
## 2024
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-factsheet-year-7-places-september-2024>
```{r}
yr_7_admissions_2024 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("279 (243)", "73 (73)", "466 (347)", "234 (199)", "110 (110)", "242 (159)",
"80 (80)", "210 (182)", "129 (129)", "456 (293)", "2279 (1815)"),
No_2nd_pref = c("268 (69)", "27 (6)", "203 (12)", "518 (111)", "252 (41)", "224 (6)",
"30 (9)", "89 (23)", "105 (38)", "394 (7)", "2110 (322)"),
No_3rd_pref = c("224 (18)", "39 (5)", "272 (1)", "262 (20)", "244 (19)", "202 (0)",
"39 (5)", "253 (20)", "77 (4)", "209 (0)", "1821 (92)"),
Total = c("771 (330)", "139 (84)", "941 (360)", "1014 (330)", "606 (170)", "668 (165)",
"149 (94)", "552 (225)", "311 (171)", "1059 (300)", "6210 (2259)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2024, extensions = 'Buttons', options = dt_opts)
```
## 2023
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-factsheet-year-7-places-september-2023>
```{r}
yr_7_admissions_2023 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("300 (260)", "92 (92)", "451 (351)", "260 (242)", "158 (132)", "241 (162)",
"134 (134)", "203 (196)", "154 (154)", "409 (292)", "2402 (2015)"),
No_2nd_pref = c("309 (61)", "34 (5)", "189 (9)", "516 (81)", "298 (40)", "229 (3)",
"38 (14)", "87 (17)", "79 (26)", "423 (8)", "2202 (264)"),
No_3rd_pref = c("231 (9)", "34 (3)", "297 (0)", "232 (7)", "258 (8)", "198 (0)",
"27 (4)", "292 (12)", "113 (20)", "233 (0)", "1915 (63)"),
Total = c("840 (330)", "160 (100)", "937 (360)", "1008 (330)", "714 (180)", "668 (165)",
"199 (152)", "582 (225)", "346 (200)", "1065 (300)", "6789 (2407)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2023, extensions = 'Buttons', options = dt_opts)
```
## 2022
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-factsheet-year-7-places-september-2022>
```{r}
yr_7_admissions_2022 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("292 (270)", "121 (121)", "381 (342)", "291 (258)", "115 (115)", "232 (160)",
"104 (104)", "218 (197)", "166 (166)", "444 (303)", "2364 (2034)"),
No_2nd_pref = c("299 (50)", "37 (8)", "182 (16)", "537 (87)", "246 (24)", "234 (3)",
"39 (10)", "80 (12)", "106 (26)", "435 (6)", "2195 (245)"),
No_3rd_pref = c("235 (10)", "77 (3)", "293 (2)", "226 (4)", "235 (10)", "203 (2)",
"45 (8)", "320 (16)", "84 (2)", "212 (1)", "1930 (57)"),
Total = c("826 (330)", "235 (132)", "856 (360)", "1054 (349)", "596 (149)", "669 (165)",
"188 (122)", "618 (225)", "356 (194)", "1091 (310)", "6789 (2407)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2022, extensions = 'Buttons', options = dt_opts)
```
## 2021
<https://web.archive.org/web/2022/https://www.brighton-hove.gov.uk/schools-and-learning/apply-school/allocation-factsheet-year-7-places-september-2021>
```{r}
yr_7_admissions_2021 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("400 (284)", "116 (116)", "414 (340)", "386 (306)", "116 (116)", "235 (150)",
"145 (145)", "211 (193)", "165 (165)", "386 (296)", "2574 (2111)"),
No_2nd_pref = c("320 (40)", "46 (9)", "226 (18)", "493 (23)", "310 (101)", "258 (14)",
"35 (12)", "83 (15)", "90 (16)", "492 (4)", "2353 (252)"),
No_3rd_pref = c("278 (6)", "49 (16)", "321 (2)", "227 (1)", "249 (30)", "280 (1)",
"46 (10)", "252 (17)", "102 (5)", "208 (0)", "2012 (88)"),
Total = c("998 (330)", "211 (141)", "961 (360)", "1106 (330)", "675 (247)", "773 (165)",
"226 (167)", "546 (225)", "357 (186)", "1086 (300)", "6939 (2501)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2021, extensions = 'Buttons', options = dt_opts)
```
## 2020
<https://www.brighton-hove.gov.uk/schools-and-learning/apply-school/allocation-factsheet-year-7-places-september-2020>
```{r}
yr_7_admissions_2020 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("350 (279)", "89 (89)", "349 (311)", "438 (357)", "80 (80)", "253 (145)",
"159 (159)", "219 (206)", "174 (174)", "307 (268)", "2418 (2068)"),
No_2nd_pref = c("281 (45)", "50 (7)", "221 (39)", "489 (7)", "227 (33)", "282 (5)",
"38 (11)", "65 (11)", "95 (21)", "530 (36)", "2278 (215)"),
No_3rd_pref = c("237 (6)", "62 (10)", "326 (10)", "203 (0)", "281 (24)", "257 (0)",
"38 (7)", "304 (8)", "77 (8)", "231 (0)", "2016 (73)"),
Total = c("868 (330)", "201 (106)", "896 (360)", "1130 (364)", "588 (137)", "792 (150)",
"235 (177)", "588 (225)", "346 (203)", "1068 (304)", NA_character_)
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2020, extensions = 'Buttons', options = dt_opts)
```
## 2019
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Secondary%20Allocation%20factsheet%202019.pdf>
```{r}
yr_7_admissions_2019 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("373 (282)", "92 (92)", "465 (340)", "447 (355)", "119 (119)", "188 (157)",
"153 (152)", "217 (190)", "168 (164)", "304 (251)", "2526 (2102)"),
No_2nd_pref = c("344 (37)", "40 (11)", "219 (14)", "470 (9)", "289 (62)", "209 (17)",
"33 (16)", "83 (19)", "106 (49)", "537 (47)", "2330 (281)"),
No_3rd_pref = c("256 (11)", "49 (7)", "344 (6)", "233 (0)", "271 (34)", "204 (4)",
"62 (15)", "299 (16)", "136 (27)", "257 (6)", "2111 (126)"),
Total = c("973 (330)", "181 (110)", "1028 (360)", "1150 (364)", "679 (215)", "601 (178)",
"248 (183)", "599 (225)", "410 (240)", "1098 (304)", "6967 (2509)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2019, extensions = 'Buttons', options = dt_opts)
```
## 2018
<https://www.brighton-hove.gov.uk/sites/default/files/migrated/subject/inline/Secondary%20Allocation%20factsheet%202018.pdf>
```{r}
yr_7_admissions_2018 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("401 (303)", "110 (109)", "381 (342)", "413 (325)", "151 (151)", "143 (117)",
"156 (156)", "226 (191)", "168 (168)", "355 (280)", "2504 (2142)"),
No_2nd_pref = c("288 (22)", "42 (15)", "162 (15)", "488 (6)", "345 (71)", "133 (7)",
"28 (8)", "64 (13)", "108 (13)", "486 (20)", "2143 (190)"),
No_3rd_pref = c("257 (5)", "50 (6)", "274 (3)", "208 (0)", "226 (17)", "159 (1)",
"47 (11)", "368 (21)", "133 (7)", "253 (0)", "1975 (71)"),
Total = c("946 (330)", "202 (130)", "816 (360)", "1109 (331)", "722 (239)", "435 (125)",
"231 (175)", "658 (225)", "409 (188)", "1094 (300)", "6622 (2403)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2018, extensions = 'Buttons', options = dt_opts)
```
## 2017
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Secondary%20Allocation%20factsheet%202017.pdf>
```{r}
yr_7_admissions_2017 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("336 (282)", "90 (90)", "362 (330)", "459 (327)", "198 (198)", "124 (94)",
"131 (131)", "211 (185)", "158 (158)", "304 (228)", "2373 (2023)"),
No_2nd_pref = c("323 (14)", "22 (9)", "154 (20)", "455 (3)", "299 (45)", "123 (4)",
"33 (8)", "90 (12)", "65 (13)", "482 (35)", "2046 (163)"),
No_3rd_pref = c("238 (4)", "31 (9)", "278 (10)", "159 (1)", "229 (12)", "142 (2)",
"39 (11)", "303 (18)", "84 (5)", "244 (7)", "1747 (79)"),
Total = c("897 (300)", "143 (108)", "794 (360)", "1073 (331)", "726 (255)", "389 (100)",
"203 (150)", "604 (215)", "307 (176)", "1030 (270)", "6167 (2282)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2017, extensions = 'Buttons', options = dt_opts)
```
## 2016
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Secondary%20Allocation%20Factsheet%202016.pdf>
```{r}
yr_7_admissions_2016 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("410 (292)", "87 (87)", "375 (338)", "495 (337)", "172 (172)", "98 (90)",
"138 (138)", "217 (185)", "85 (85)", "219 (176)", "2296 (1900)"),
No_2nd_pref = c("289 (6)", "24 (7)", "130 (17)", "337 (7)", "335 (82)", "102 (9)",
"31 (14)", "63 (20)", "39 (8)", "521 (100)", "1871 (270)"),
No_3rd_pref = c("189 (2)", "27 (5)", "330 (5)", "191 (0)", "224 (19)", "142 (1)",
"33 (11)", "237 (10)", "58 (1)", "217 (8)", "1648 (62)"),
Total = c("888 (300)", "138 (99)", "835 (360)", "1023 (344)", "731 (273)", "342 (100)",
"202 (163)", "517 (215)", "182 (94)", "957 (284)", "5815 (2232)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2016, extensions = 'Buttons', options = dt_opts)
```
## 2015
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Secondary%20Allocation%20factsheet%202015.pdf>
```{r}
yr_7_admissions_2015 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("389 (287)", "120 (120)", "385 (345)", "471 (329)", "215 (200)", "90 (90)",
"141 (141)", "208 (184)", "86 (86)", "210 (176)", "2315 (1958)"),
No_2nd_pref = c("323 (13)", "17 (4)", "132 (13)", "366 (4)", "387 (77)", "71 (17)",
"34 (9)", "72 (13)", "35 (24)", "480 (94)", "1917 (268)"),
No_3rd_pref = c("234 (1)", "39 (6)", "306 (2)", "189 (4)", "220 (22)", "108 (14)",
"33 (6)", "250 (13)", "70 (13)", "233 (7)", "1682 (88)"),
Total = c("946 (301)", "176 (130)", "823 (360)", "1026 (337)", "823 (299)", "269 (121)",
"208 (156)", "530 (210)", "191 (123)", "923 (277)", "5915 (2214)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2015, extensions = 'Buttons', options = dt_opts)
```
## 2014
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Secondary%20Allocation%20factsheet%202014.pdf>
```{r}
yr_7_admissions_2014 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Kings School", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("349 (286)", "68 (68)", "393 (355)", "493 (316)", "181 (181)", "105 (105)",
"191 (191)", "209 (179)", "85 (85)", "216 (165)", "2290 (1931)"),
No_2nd_pref = c("287 (11)", "18 (5)", "135 (3)", "414 (15)", "343 (58)", "54 (7)",
"38 (12)", "67 (11)", "38 (6)", "495 (95)", "1889 (223)"),
No_3rd_pref = c("212 (3)", "27 (9)", "296 (2)", "178 (0)", "195 (21)", "77 (3)",
"32 (6)", "222 (20)", "39 (1)", "254 (10)", "1532 (75)"),
Total = c("848 (300)", "113 (82)", "824 (360)", "1085 (331)", "719 (260)", "236 (115)",
"261 (209)", "498 (210)", "162 (92)", "965 (270)", "6654 (1975)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2014, extensions = 'Buttons', options = dt_opts)
```
## 2013
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Secondary%20Allocation%20factsheet%202013.pdf>
```{r}
yr_7_admissions_2013 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Longhill High", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("400 (285)", "79 (79)", "409 (354)", "507 (326)", "175 (175)", "188 (188)",
"186 (180)", "80 (80)", "169 (142)", "2193 (1809)"),
No_2nd_pref = c("333 (14)", "17 (3)", "129 (5)", "370 (4)", "333 (95)", "26 (8)",
"65 (17)", "30 (9)", "485 (110)", "1788 (265)"),
No_3rd_pref = c("228 (1)", "19 (1)", "300 (1)", "182 (0)", "234 (29)", "43 (4)",
"198 (13)", "62 (2)", "253 (18)", "1519 (69)"),
Total = c("961 (300)", "115 (83)", "838 (360)", "1059 (330)", "742 (299)", "257 (200)",
"449 (210)", "172 (91)", "907 (270)", "5500 (2143)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2013, extensions = 'Buttons', options = dt_opts)
```
Kings School did not admit Year 7 until 2014, so it is absent from 2013.
## 2012
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/downloads/education/school_admissions_2004_05/Secondary_Allocation_factsheet_2012.doc>
```{r}
yr_7_admissions_2012 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Longhill", "Patcham High",
"Portslade Aldridge Community Academy", "Varndean", "Total"),
No_1st_pref = c("465 (284)", "97 (97)", "362 (331)", "460 (326)", "148 (147)", "210 (210)",
"193 (183)", "104 (104)", "184 (168)", "2223 (1850)"),
No_2nd_pref = c("346 (13)", "33 (5)", "136 (10)", "382 (3)", "358 (117)", "54 (15)",
"97 (21)", "39 (15)", "507 (92)", "1952 (291)"),
No_3rd_pref = c("233 (3)", "42 (1)", "256 (0)", "256 (1)", "271 (35)", "56 (11)",
"206 (5)", "55 (2)", "258 (9)", "1633 (67)"),
Total = c("1044 (300)", "172 (103)", "754 (341)", "1098 (330)", "778 (300)", "320 (236)",
"497 (210)", "198 (121)", "950 (270)", "5811 (2211)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2012, extensions = 'Buttons', options = dt_opts)
```
Kings School did not admit Year 7 until 2014. The 2012 factsheet notes that one additional applicant each listed Hove Park and Patcham High as a 4th preference, and one applicant listed Varndean as a 5th preference — these three applicants are included in the per-school `Total` but sit outside the 1st / 2nd / 3rd columns, so row totals for those schools are one higher than the sum of the preference columns.
## 2010
<https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/downloads/education/Secondary_School_Admissions_2011-12.pdf>
Data extracted from page 16 (the "List of Secondary Schools" table) of the Secondary School Admissions 2011-12 booklet, which reports preferences and offers for the September 2010 intake. The booklet published offers broken down by admission priority rather than by preference rank, so per-preference offer counts are `NA`. Cardinal Newman's 2010 offer breakdown was not published in the booklet and is also `NA`.
```{r}
yr_7_admissions_2010 <- data.frame(
School = c("Blatchington Mill", "Brighton Aldridge Community Academy", "Cardinal Newman",
"Dorothy Stringer", "Hove Park", "Longhill", "Patcham High",
"Portslade Community College", "Varndean", "Total"),
No_1st_pref = c("528 (NA)", "115 (NA)", "380 (NA)", "377 (NA)", "142 (NA)", "221 (NA)",
"187 (NA)", "115 (NA)", "255 (NA)", "2320 (NA)"),
No_2nd_pref = c("354 (NA)", "41 (NA)", "143 (NA)", "443 (NA)", "436 (NA)", "46 (NA)",
"74 (NA)", "25 (NA)", "478 (NA)", "2040 (NA)"),
No_3rd_pref = c("243 (NA)", "29 (NA)", "227 (NA)", "275 (NA)", "266 (NA)", "57 (NA)",
"178 (NA)", "88 (NA)", "274 (NA)", "1637 (NA)"),
Total = c("1125 (300)", "185 (123)", "750 (NA)", "1095 (311)", "844 (308)", "324 (239)",
"439 (210)", "228 (139)", "1007 (270)", "5997 (NA)")
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(yr_7_admissions_2010, extensions = 'Buttons', options = dt_opts)
```
Kings School did not admit Year 7 until 2014. "Portslade Community College" is the earlier name for what became **Portslade Aldridge Community Academy** — the two are normalised to the academy name in the combined dataset below.
:::
## Combined Secondary Data 2010–2026
```{r}
secondary_frames <- list(
`2010` = yr_7_admissions_2010, `2012` = yr_7_admissions_2012,
`2013` = yr_7_admissions_2013, `2014` = yr_7_admissions_2014,
`2015` = yr_7_admissions_2015, `2016` = yr_7_admissions_2016,
`2017` = yr_7_admissions_2017, `2018` = yr_7_admissions_2018,
`2019` = yr_7_admissions_2019, `2020` = yr_7_admissions_2020,
`2021` = yr_7_admissions_2021, `2022` = yr_7_admissions_2022,
`2023` = yr_7_admissions_2023, `2024` = yr_7_admissions_2024,
`2025` = yr_7_admissions_2025, `2026` = yr_7_admissions_2026
)
secondary_all <- bind_rows(lapply(names(secondary_frames), function(y) {
secondary_frames[[y]] %>% mutate(Year = as.integer(y))
}))
# Normalise historic naming and drop the Total row from the long dataset.
secondary_all <- secondary_all %>%
mutate(School = case_when(
School == "Longhill" ~ "Longhill High",
School == "Portslade Community College" ~ "Portslade Aldridge Community Academy",
TRUE ~ School
))
datatable(
secondary_all %>% select(Year, School, everything()),
extensions = 'Buttons',
options = dt_opts,
filter = "top"
)
```
## Interactive Time Series — Secondary Applications and Offers
The checkboxes below toggle schools in **all three** charts in this section (Total Applications, Applications per Preference Slot, Total Offers). All schools are selected by default. (You can also click a school in a plot legend to hide/show it there, or double-click to isolate a single school.)
```{r}
sec_schools <- secondary_all %>%
filter(School != "Total") %>%
mutate(
slots = ifelse(Year >= 2026, 4L, 3L),
PerSlot = Total / slots
)
sec_shared <- SharedData$new(sec_schools, key = ~School, group = "sec-main")
sec_filter <- filter_checkbox(
id = "sec_school_filter",
label = "Schools",
sharedData = sec_shared,
group = ~School,
inline = TRUE
)
```
```{r}
sec_filter
```
### Total Applications by School
```{r}
p_apps <- ggplot(sec_shared,
aes(x = Year, y = Total, color = School, group = School,
text = paste0(School, "<br>Year: ", Year,
"<br>Applications: ", Total,
"<br>Offers: ", Total_offer))) +
geom_line() +
geom_point() +
scale_x_continuous(breaks = seq(2010, 2026, 1)) +
labs(title = "Total Applications by Secondary School, 2010–2026",
x = "Year", y = "Total Applications (preferences received)") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p_apps, tooltip = "text") %>%
highlight(on = "plotly_click", off = "plotly_doubleclick",
persistent = FALSE, dynamic = FALSE)
```
### Applications per Preference Slot
From 2010 to 2025 Brighton & Hove families could list up to **three** preferences on the secondary-transfer form, so "total applications" for a school was the sum of 1st, 2nd and 3rd preferences. From **2026** onwards the council added a **4th** preference slot, which mechanically inflates the "total applications" figure because every family now has one more slot to fill. To separate that mechanical jump from any real change in demand, the chart below divides each school's total applications by the number of preference slots available in that year.
```{r}
# slots / PerSlot already present in sec_shared (built once above).
p_apps_std <- ggplot(sec_shared,
aes(x = Year, y = PerSlot, color = School, group = School,
text = paste0(School, "<br>Year: ", Year,
"<br>Slots available: ", slots,
"<br>Total applications: ", Total,
"<br>Applications / slot: ",
formatC(PerSlot, format = "f", digits = 1)))) +
geom_line() +
geom_point() +
scale_x_continuous(breaks = seq(2010, 2026, 1)) +
labs(title = "Applications per Preference Slot, Secondary 2010–2026",
x = "Year",
y = "Total applications ÷ preference slots available") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p_apps_std, tooltip = "text") %>%
highlight(on = "plotly_click", off = "plotly_doubleclick",
persistent = FALSE, dynamic = FALSE)
```
On this slot-standardised basis a line that is broadly flat between 2025 and 2026 indicates that the jump in the raw total is almost entirely the extra slot — not a genuine surge in demand. A school whose 2026 point is still clearly higher than its 2025 point is picking up genuine extra preferences per slot.
### Total Offers by School
```{r}
p_offers <- ggplot(sec_shared,
aes(x = Year, y = Total_offer, color = School, group = School,
text = paste0(School, "<br>Year: ", Year,
"<br>Offers: ", Total_offer))) +
geom_line() +
geom_point() +
scale_x_continuous(breaks = seq(2010, 2026, 1)) +
labs(title = "Total Offers by Secondary School, 2010–2026",
x = "Year", y = "Total Offers") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p_offers, tooltip = "text") %>%
highlight(on = "plotly_click", off = "plotly_doubleclick",
persistent = FALSE, dynamic = FALSE)
```
### Preference Composition (Faceted by School)
```{r}
sec_prefs_long <- sec_schools %>%
select(School, Year, No_1st_pref, No_2nd_pref, No_3rd_pref, No_4th_pref) %>%
pivot_longer(cols = starts_with("No_"),
names_to = "Preference", values_to = "Count") %>%
mutate(
Preference = recode(Preference,
No_1st_pref = "1st", No_2nd_pref = "2nd",
No_3rd_pref = "3rd", No_4th_pref = "4th"),
SchoolShort = recode(School,
"Brighton Aldridge Community Academy" = "BACA",
"Portslade Aldridge Community Academy" = "PACA")
) %>%
filter(!is.na(Count))
p_prefs <- ggplot(sec_prefs_long,
aes(x = Year, y = Count, color = Preference, group = Preference,
text = paste0(School, " (", Preference, ")<br>",
"Year: ", Year, "<br>Count: ", Count))) +
geom_line() +
geom_point(size = 0.6) +
facet_wrap(~ SchoolShort, scales = "fixed") +
scale_color_manual(values = wes_palette("Darjeeling1", n = 4)) +
scale_x_continuous(breaks = seq(2010, 2026, 2)) +
labs(title = "Preferences Received by School and Year",
x = "Year", y = "Preferences Received") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5))
ggplotly(p_prefs, tooltip = "text")
```
### Offers by Preference (Faceted by School)
```{r}
sec_offers_long <- sec_schools %>%
select(School, Year, No_1st_pref_offer, No_2nd_pref_offer,
No_3rd_pref_offer, No_4th_pref_offer) %>%
pivot_longer(cols = starts_with("No_"),
names_to = "Preference", values_to = "Count") %>%
mutate(
Preference = recode(Preference,
No_1st_pref_offer = "1st", No_2nd_pref_offer = "2nd",
No_3rd_pref_offer = "3rd", No_4th_pref_offer = "4th"),
SchoolShort = recode(School,
"Brighton Aldridge Community Academy" = "BACA",
"Portslade Aldridge Community Academy" = "PACA")
) %>%
filter(!is.na(Count))
p_offers_prefs <- ggplot(sec_offers_long,
aes(x = Year, y = Count, color = Preference, group = Preference,
text = paste0(School, " (", Preference, ")<br>",
"Year: ", Year, "<br>Offers: ", Count))) +
geom_line() +
geom_point(size = 0.6) +
facet_wrap(~ SchoolShort, scales = "fixed") +
scale_color_manual(values = wes_palette("BottleRocket2", n = 4)) +
scale_x_continuous(breaks = seq(2010, 2026, 2)) +
labs(title = "Offers Made by Preference Rank, by School",
x = "Year", y = "Offers") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5))
ggplotly(p_offers_prefs, tooltip = "text")
```
### Smaller Schools Focus
```{r}
small_schools <- sec_schools %>%
filter(School %in% c("Longhill High", "Kings School",
"Brighton Aldridge Community Academy",
"Portslade Aldridge Community Academy"))
p_small <- ggplot(small_schools,
aes(x = Year, y = Total_offer, color = School, group = School,
text = paste0(School, "<br>Year: ", Year,
"<br>Offers: ", Total_offer))) +
geom_line() +
geom_point() +
scale_x_continuous(breaks = seq(2010, 2026, 1)) +
labs(title = "Total Offers — Smaller Schools",
x = "Year", y = "Total Offers") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p_small, tooltip = "text")
```
# Primary Schools — Reception Allocations
Data transcribed from Brighton & Hove City Council Infant & Primary School Reception Allocation Factsheets. Most years report three preferences; 2026 adds a 4th. Older factsheets (2014-2018) were recovered from the Internet Archive. Data for 2013 could not be located.
Click a year to view that factsheet.
::: panel-tabset
## 2026
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-infantprimary-school-reception-places-september-2026>
```{r}
primary_2026 <- data.frame(
School = c("Aldrington CE Primary", "Balfour Primary", "Benfield Primary",
"Bevendean Primary", "Bilingual Primary", "Brackenbury Primary",
"Brunswick Primary", "Carden Primary", "Carlton Hill Primary",
"City Academy Whitehawk", "Coldean Primary", "Coombe Road Primary",
"Cottesmore St Mary Catholic Primary", "Downs Infant", "Elm Grove Primary",
"Fairlight Primary", "Goldstone Primary", "Hangleton Primary",
"Hertford Primary", "Middle Street Primary", "Mile Oak Primary",
"Moulsecoomb Primary", "Our Lady of Lourdes Catholic Primary",
"Patcham Infant", "Peter Gladwin Primary", "Queens Park Primary",
"Rudyard Kipling Primary", "Saltdean Primary", "St Andrew's CE Primary",
"St Bernadette's Catholic Primary", "St John The Baptist Catholic Primary",
"St Luke's Primary", "St Margaret's CE Primary", "St Mark's CE Primary",
"St Martin's CE Primary", "St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary", "St Nicolas CE Primary", "St Paul's CE Primary",
"Stanford Infant", "West Blatchington Primary",
"West Hove Infant, Holland Road", "West Hove Infant, Portland Road",
"Westdene Primary", "Woodingdean Primary"),
PAN = c(60,90,30,60,90,30,90,60,30,60,60,30,60,90,60,60,90,60,30,30,60,30,30,
90,30,60,60,60,90,30,30,90,30,30,30,30,30,60,30,60,30,60,120,60,30),
No_1st_pref = c("52 (52)","119 (90)","9 (9)","31 (31)","83 (83)","17 (17)",
"50 (50)","31 (31)","36 (30)","39 (39)","34 (34)","24 (24)",
"74 (60)","80 (80)","63 (58)","41 (41)","72 (72)","29 (29)",
"16 (16)","2 (2)","39 (39)","17 (17)","28 (27)","77 (77)",
"40 (30)","13 (13)","56 (52)","70 (60)","100 (88)","36 (29)",
"14 (14)","94 (86)","26 (26)","13 (13)","27 (27)","8 (8)",
"25 (25)","51 (51)","20 (20)","49 (49)","15 (15)","52 (52)",
"129 (118)","55 (53)","42 (30)"),
No_2nd_pref = c("53 (6)","76 (0)","20 (0)","20 (2)","52 (6)","35 (2)",
"44 (3)","38 (1)","35 (0)","7 (1)","14 (2)","21 (0)",
"52 (0)","88 (10)","65 (2)","56 (3)","59 (2)","34 (1)",
"25 (1)","3 (0)","7 (0)","16 (0)","42 (2)","60 (6)",
"46 (0)","29 (2)","22 (7)","14 (0)","107 (2)","32 (1)",
"15 (0)","68 (4)","47 (1)","18 (2)","10 (0)","2 (0)",
"18 (2)","41 (5)","5 (0)","40 (7)","14 (0)","80 (4)",
"89 (2)","67 (4)","47 (0)"),
No_3rd_pref = c("29 (0)","66 (0)","17 (0)","21 (0)","78 (0)","33 (0)",
"37 (0)","33 (0)","53 (0)","7 (0)","10 (0)","23 (2)",
"45 (0)","67 (0)","54 (0)","54 (0)","75 (1)","21 (0)",
"21 (1)","14 (0)","7 (0)","9 (0)","34 (1)","19 (1)",
"21 (0)","21 (3)","13 (1)","19 (0)","65 (0)","40 (0)",
"13 (1)","47 (0)","28 (0)","10 (1)","19 (0)","11 (1)",
"18 (1)","31 (0)","6 (0)","59 (2)","24 (0)","49 (0)",
"70 (0)","49 (3)","11 (0)"),
No_4th_pref = c("35 (0)","40 (0)","30 (0)","6 (0)","47 (0)","17 (0)",
"24 (0)","19 (0)","26 (0)","1 (0)","7 (0)","20 (0)",
"24 (0)","40 (0)","43 (0)","38 (0)","37 (0)","14 (0)",
"19 (0)","13 (0)","9 (0)","8 (0)","14 (0)","14 (0)",
"13 (0)","24 (0)","15 (0)","4 (0)","36 (0)","33 (0)",
"7 (0)","34 (0)","17 (0)","4 (0)","22 (0)","15 (0)",
"13 (0)","9 (0)","10 (0)","43 (0)","15 (0)","36 (0)",
"34 (0)","23 (0)","10 (0)"),
Total = c("169 (58)","301 (90)","76 (9)","78 (33)","260 (89)","102 (19)",
"155 (53)","121 (32)","150 (30)","54 (40)","65 (36)","88 (26)",
"195 (60)","275 (90)","225 (60)","189 (44)","243 (75)","98 (30)",
"81 (18)","32 (2)","63 (39)","50 (17)","118 (30)","170 (84)",
"120 (30)","87 (18)","106 (60)","107 (60)","308 (90)","141 (30)",
"49 (15)","243 (90)","118 (27)","45 (16)","78 (27)","36 (9)",
"74 (28)","132 (56)","41 (20)","191 (58)","69 (15)","218 (56)",
"322 (120)","194 (60)","110 (30)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "No_4th_pref", "Total"))
datatable(primary_2026, extensions = 'Buttons', options = dt_opts)
```
Notes: Middle Street Primary proposed for closure August 2026 (decision 21 May 2026). Rudyard Kipling received an extra 15 places for Woodingdean-area residents.
## 2025
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-infantprimary-school-reception-places-september-2025>
```{r}
primary_2025 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bernadette's Catholic Primary",
"St John The Baptist Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"Stanford Infant","West Blatchington Primary","West Hove Infant, Holland Road",
"West Hove Infant, Portland Road","Westdene Primary","Woodingdean Primary"),
PAN = c(60,90,30,60,90,30,90,60,30,60,60,30,60,90,60,60,90,60,30,30,60,30,30,90,30,
60,30,60,90,30,30,90,30,30,30,30,30,60,30,60,30,60,120,60,60),
No_1st_pref = c("44 (44)","98 (82)","31 (30)","31 (31)","96 (78)","24 (24)","40 (40)",
"36 (36)","42 (29)","34 (34)","22 (22)","32 (30)","85 (60)","105 (84)",
"52 (52)","48 (48)","76 (76)","27 (27)","24 (20)","17 (17)","38 (38)",
"23 (23)","34 (31)","71 (71)","19 (19)","32 (32)","36 (29)","58 (58)",
"114 (90)","36 (29)","25 (25)","95 (86)","25 (25)","15 (15)","25 (25)",
"22 (22)","35 (30)","50 (50)","23 (23)","56 (52)","20 (20)","45 (45)",
"100 (100)","62 (55)","26 (26)"),
No_2nd_pref = c("37 (6)","82 (7)","25 (0)","13 (0)","84 (9)","31 (0)","52 (3)","25 (2)",
"44 (1)","10 (1)","10 (0)","22 (0)","56 (0)","103 (5)","89 (4)","52 (5)",
"52 (4)","45 (0)","27 (9)","22 (5)","11 (1)","12 (1)","37 (0)","62 (8)",
"41 (2)","13 (3)","27 (1)","20 (2)","90 (0)","22 (1)","11 (3)","63 (3)",
"32 (1)","24 (0)","20 (0)","20 (3)","22 (0)","45 (2)","14 (1)","46 (5)",
"23 (0)","63 (9)","81 (6)","57 (4)","36 (9)"),
No_3rd_pref = c("40 (4)","80 (1)","29 (0)","15 (0)","83 (3)","51 (1)","49 (4)","28 (1)",
"49 (0)","3 (0)","6 (0)","26 (0)","38 (0)","56 (1)","73 (0)","45 (0)",
"73 (3)","22 (1)","30 (1)","24 (1)","9 (0)","8 (0)","22 (0)","22 (2)",
"25 (0)","34 (0)","11 (0)","26 (0)","57 (0)","35 (0)","10 (0)","52 (1)",
"31 (1)","9 (0)","20 (0)","19 (0)","16 (0)","31 (0)","17 (1)","73 (3)",
"21 (0)","44 (1)","59 (1)","37 (1)","17 (0)"),
Total = c("121 (54)","260 (90)","85 (30)","59 (31)","263 (90)","106 (25)","141 (47)",
"89 (39)","135 (30)","47 (35)","38 (22)","80 (30)","179 (60)","264 (90)",
"214 (56)","145 (53)","201 (83)","94 (28)","81 (30)","63 (23)","58 (39)",
"43 (24)","93 (31)","155 (81)","85 (21)","79 (35)","74 (30)","104 (60)",
"261 (90)","93 (30)","46 (28)","210 (90)","88 (27)","48 (15)","65 (25)",
"61 (25)","73 (30)","126 (52)","54 (25)","175 (60)","64 (20)","152 (55)",
"240 (107)","156 (60)","79 (35)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2025, extensions = 'Buttons', options = dt_opts)
```
## 2024
<https://www.brighton-hove.gov.uk/schools-and-learning/allocation-infant-and-primary-school-reception-places-september-2024>
```{r}
primary_2024 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bernadette's Catholic Primary",
"St John The Baptist Catholic Primary","St Joseph's Catholic Primary",
"St Luke's Primary","St Margaret's CE Primary","St Mark's CE Primary",
"St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"Stanford Infant","West Blatchington Primary","West Hove Infant, Holland Road",
"West Hove Infant, Portland Road","Westdene Primary","Woodingdean Primary"),
PAN = c(60,90,30,60,90,30,90,60,30,60,60,30,60,90,60,60,90,60,30,30,60,30,30,90,30,
60,60,90,90,30,30,30,90,30,30,30,30,30,60,30,90,30,60,120,60,60),
No_1st_pref = c("47 (47)","69 (69)","28 (28)","35 (35)","78 (78)","28 (28)","63 (63)",
"39 (39)","40 (26)","56 (56)","33 (33)","20 (20)","80 (59)","105 (87)",
"65 (56)","46 (46)","87 (83)","30 (30)","28 (25)","18 (18)","46 (46)",
"21 (21)","36 (30)","70 (70)","31 (30)","33 (33)","36 (36)","75 (75)",
"106 (88)","29 (28)","34 (27)","13 (13)","135 (89)","14 (14)","14 (14)",
"21 (21)","23 (23)","21 (21)","51 (51)","17 (17)","60 (60)","36 (30)",
"43 (43)","119 (112)","74 (59)","44 (44)"),
No_2nd_pref = c("47 (6)","71 (8)","25 (0)","9 (1)","56 (4)","38 (2)","56 (4)","30 (1)",
"51 (3)","9 (1)","17 (1)","25 (4)","61 (1)","80 (2)","110 (4)","33 (6)",
"62 (7)","51 (2)","27 (5)","21 (4)","12 (1)","19 (2)","30 (0)","76 (7)",
"36 (0)","23 (5)","33 (3)","18 (3)","91 (2)","32 (2)","17 (3)","9 (0)",
"98 (1)","31 (1)","23 (2)","17 (1)","9 (1)","30 (1)","51 (4)","25 (3)",
"47 (1)","21 (0)","74 (5)","89 (8)","51 (1)","33 (0)"),
No_3rd_pref = c("43 (1)","63 (2)","36 (0)","17 (0)","72 (5)","40 (0)","59 (1)","31 (1)",
"49 (1)","5 (1)","4 (0)","23 (0)","32 (0)","57 (1)","96 (0)","55 (4)",
"79 (0)","29 (0)","41 (0)","30 (2)","13 (0)","5 (0)","20 (0)","23 (2)",
"23 (0)","36 (6)","9 (1)","14 (0)","58 (0)","29 (0)","20 (0)","7 (0)",
"52 (0)","28 (1)","15 (0)","31 (0)","17 (1)","23 (1)","31 (0)","19 (1)",
"76 (0)","28 (0)","33 (0)","59 (0)","48 (0)","15 (3)"),
Total = c("137 (54)","203 (79)","89 (28)","61 (36)","206 (87)","106 (30)","178 (68)",
"100 (41)","140 (30)","70 (58)","54 (34)","68 (24)","173 (60)","242 (90)",
"271 (60)","134 (56)","228 (90)","110 (32)","96 (30)","69 (24)","71 (47)",
"45 (23)","86 (30)","169 (79)","90 (30)","92 (44)","78 (40)","107 (78)",
"255 (90)","90 (30)","71 (30)","29 (13)","285 (90)","73 (16)","52 (16)",
"69 (22)","49 (25)","74 (23)","133 (55)","61 (21)","183 (61)","85 (30)",
"150 (48)","267 (120)","173 (60)","92 (47)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2024, extensions = 'Buttons', options = dt_opts)
```
## 2023
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-factsheet-infant-and-primary-school-reception-places-september-2023>
```{r}
primary_2023 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,90,30,60,90,30,120,60,30,60,60,30,60,120,60,60,90,60,30,30,60,30,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,30,60,120,60,60),
No_1st_pref = c("36 (36)","75 (75)","20 (20)","30 (30)","109 (87)","28 (27)","77 (77)",
"27 (27)","44 (29)","44 (44)","35 (35)","17 (17)","76 (60)","113 (113)",
"70 (60)","42 (42)","86 (83)","50 (50)","22 (22)","29 (25)","48 (48)",
"10 (10)","36 (30)","77 (77)","47 (30)","30 (30)","40 (40)","71 (71)",
"100 (85)","14 (14)","20 (20)","23 (23)","15 (15)","78 (78)","21 (21)",
"14 (14)","17 (17)","17 (17)","24 (24)","54 (54)","26 (26)","11 (11)",
"59 (59)","30 (29)","39 (39)","102 (102)","52 (52)","43 (43)"),
No_2nd_pref = c("40 (4)","64 (0)","25 (0)","10 (1)","65 (2)","32 (3)","55 (2)","16 (0)",
"45 (1)","14 (0)","14 (0)","15 (0)","29 (0)","104 (2)","89 (0)","23 (3)",
"65 (6)","49 (4)","36 (0)","23 (5)","15 (4)","7 (0)","39 (0)","61 (1)",
"50 (0)","34 (5)","21 (0)","8 (1)","89 (5)","7 (0)","16 (1)","13 (0)",
"9 (1)","78 (5)","39 (2)","17 (0)","23 (0)","8 (0)","21 (1)","53 (6)",
"13 (2)","11 (0)","68 (9)","25 (0)","79 (3)","99 (11)","58 (1)","44 (0)"),
No_3rd_pref = c("38 (1)","71 (1)","24 (0)","13 (0)","74 (1)","41 (0)","61 (0)","31 (0)",
"54 (0)","10 (0)","4 (0)","17 (0)","29 (0)","58 (1)","56 (0)","52 (0)",
"72 (1)","38 (0)","48 (0)","19 (0)","9 (1)","5 (0)","23 (0)","23 (0)",
"36 (0)","44 (0)","11 (0)","27 (0)","44 (0)","13 (0)","30 (0)","16 (0)",
"10 (0)","65 (4)","19 (0)","6 (0)","25 (0)","14 (0)","20 (0)","47 (0)",
"19 (0)","5 (0)","65 (0)","22 (1)","54 (0)","71 (1)","30 (0)","15 (0)"),
Total = c("114 (41)","210 (76)","69 (20)","53 (31)","248 (90)","101 (30)","193 (79)",
"74 (27)","143 (30)","68 (44)","53 (35)","49 (17)","134 (60)","275 (116)",
"215 (60)","117 (45)","223 (90)","137 (54)","106 (22)","71 (30)","72 (53)",
"22 (10)","98 (30)","161 (78)","133 (30)","108 (35)","72 (40)","106 (72)",
"233 (90)","34 (14)","66 (21)","52 (23)","34 (16)","221 (87)","79 (23)",
"37 (14)","65 (17)","39 (17)","65 (25)","154 (60)","58 (28)","27 (11)",
"192 (68)","77 (30)","172 (42)","272 (114)","140 (53)","102 (43)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2023, extensions = 'Buttons', options = dt_opts)
```
## 2022
<https://www.brighton-hove.gov.uk/schools-and-learning/school-policies-reports-strategies-and-other-documents/allocation-infantprimary-school-reception-places-september-2022>
```{r}
primary_2022 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,90,30,60,90,30,120,60,30,60,60,30,60,120,60,60,90,60,60,30,60,30,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,30,60,120,60,60),
No_1st_pref = c("57 (57)","86 (85)","31 (29)","34 (34)","93 (89)","16 (16)","85 (85)",
"45 (45)","40 (29)","41 (41)","40 (40)","26 (26)","73 (59)","100 (100)",
"59 (53)","36 (36)","80 (80)","41 (41)","26 (26)","25 (25)","34 (34)",
"12 (12)","20 (20)","87 (87)","39 (30)","31 (31)","49 (49)","72 (72)",
"93 (83)","13 (13)","38 (30)","22 (22)","14 (14)","115 (88)","21 (21)",
"15 (15)","21 (21)","20 (20)","39 (30)","48 (48)","17 (17)","17 (17)",
"84 (83)","29 (29)","27 (27)","134 (119)","62 (59)","48 (48)"),
No_2nd_pref = c("39 (1)","61 (4)","36 (1)","13 (0)","86 (0)","34 (6)","85 (3)","29 (0)",
"43 (1)","9 (4)","11 (1)","23 (0)","58 (1)","109 (0)","87 (7)","42 (2)",
"88 (5)","46 (1)","46 (0)","13 (1)","11 (3)","19 (0)","31 (0)","67 (1)",
"41 (0)","45 (12)","27 (0)","10 (1)","89 (7)","15 (2)","29 (0)","16 (0)",
"12 (0)","73 (2)","34 (0)","26 (0)","21 (1)","19 (0)","22 (0)","47 (8)",
"23 (0)","11 (1)","58 (7)","15 (1)","81 (5)","69 (1)","53 (0)","47 (1)"),
No_3rd_pref = c("46 (2)","86 (1)","36 (0)","18 (0)","91 (1)","19 (0)","66 (1)","26 (0)",
"45 (0)","7 (1)","10 (0)","25 (0)","41 (0)","70 (0)","88 (0)","46 (2)",
"56 (0)","29 (0)","56 (0)","18 (0)","6 (0)","6 (0)","32 (0)","33 (1)",
"36 (0)","49 (2)","13 (0)","16 (0)","70 (0)","16 (0)","28 (0)","11 (0)",
"11 (0)","55 (0)","21 (0)","10 (0)","31 (1)","15 (0)","11 (0)","46 (4)",
"27 (0)","12 (1)","62 (0)","17 (0)","62 (1)","71 (0)","44 (1)","10 (1)"),
Total = c("142 (60)","233 (90)","103 (30)","65 (34)","270 (90)","69 (22)","236 (89)",
"100 (45)","128 (30)","57 (46)","61 (41)","74 (26)","172 (60)","279 (100)",
"234 (60)","124 (40)","224 (85)","116 (42)","128 (26)","56 (26)","51 (37)",
"37 (12)","84 (20)","187 (89)","116 (30)","125 (45)","89 (49)","98 (73)",
"252 (90)","44 (15)","95 (30)","49 (22)","38 (14)","243 (90)","76 (21)",
"51 (15)","73 (23)","54 (20)","72 (30)","141 (60)","67 (17)","40 (19)",
"204 (90)","61 (30)","170 (33)","274 (120)","160 (60)","105 (50)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2022, extensions = 'Buttons', options = dt_opts)
```
## 2021
<https://web.archive.org/web/2025/https://www.brighton-hove.gov.uk/schools-and-learning/apply-school/allocation-infantprimary-school-reception-places-september-2021>
```{r}
primary_2021 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,90,30,120,60,30,60,60,30,60,120,60,60,90,60,60,30,60,60,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,60,120,60,60),
No_1st_pref = c("55 (54)","67 (67)","22 (22)","34 (34)","103 (83)","17 (17)","105 (105)",
"37 (37)","41 (28)","45 (45)","31 (31)","31 (29)","64 (55)","133 (114)",
"57 (48)","30 (30)","82 (82)","48 (48)","33 (33)","27 (24)","41 (41)",
"18 (18)","28 (28)","84 (84)","50 (30)","36 (36)","35 (35)","73 (73)",
"113 (88)","13 (13)","37 (30)","18 (18)","28 (28)","131 (87)","30 (29)",
"18 (18)","22 (22)","14 (14)","23 (23)","63 (56)","27 (27)","13 (13)",
"72 (72)","24 (24)","35 (35)","118 (113)","78 (60)","69 (59)"),
No_2nd_pref = c("44 (5)","69 (10)","38 (5)","22 (2)","85 (6)","25 (1)","70 (11)","24 (1)",
"50 (2)","14 (1)","10 (0)","19 (1)","56 (5)","118 (4)","105 (11)","42 (6)",
"54 (2)","41 (4)","48 (2)","38 (5)","8 (3)","9 (0)","40 (1)","68 (3)",
"39 (0)","36 (14)","52 (8)","11 (0)","87 (2)","15 (1)","19 (0)","14 (2)",
"9 (0)","86 (3)","37 (1)","18 (0)","19 (2)","15 (0)","27 (4)","62 (4)",
"11 (0)","12 (0)","64 (6)","20 (3)","138 (8)","91 (5)","57 (0)","31 (1)"),
No_3rd_pref = c("45 (1)","87 (4)","44 (0)","20 (1)","95 (1)","39 (2)","64 (1)","32 (1)",
"68 (0)","5 (0)","14 (0)","17 (0)","43 (0)","75 (2)","62 (1)","62 (3)",
"76 (1)","40 (0)","55 (1)","28 (1)","20 (4)","7 (0)","38 (0)","30 (2)",
"29 (0)","47 (1)","9 (0)","26 (0)","59 (0)","12 (1)","34 (0)","20 (1)",
"10 (0)","48 (0)","26 (0)","6 (0)","32 (3)","13 (0)","18 (0)","33 (0)",
"20 (1)","11 (0)","78 (0)","19 (0)","80 (3)","71 (2)","34 (0)","18 (0)"),
Total = c("144 (60)","223 (81)","104 (27)","76 (37)","283 (90)","81 (20)","239 (117)",
"93 (39)","159 (30)","64 (46)","55 (31)","67 (30)","163 (60)","326 (120)",
"224 (60)","134 (39)","212 (85)","129 (52)","136 (36)","93 (30)","69 (48)",
"34 (18)","106 (29)","182 (89)","118 (30)","119 (51)","96 (43)","110 (73)",
"259 (90)","40 (15)","90 (30)","52 (21)","47 (28)","265 (90)","93 (30)",
"42 (18)","73 (27)","42 (14)","68 (27)","158 (60)","58 (28)","36 (13)",
"214 (78)","63 (27)","253 (46)","280 (120)","169 (60)","118 (60)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2021, extensions = 'Buttons', options = dt_opts)
```
## 2020
<https://www.brighton-hove.gov.uk/sites/default/files/2020-04/Primary%20Allocation%20Fact%20Sheet%202020.pdf>
```{r}
primary_2020 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,90,30,120,60,30,60,60,30,60,120,60,60,90,90,60,30,90,60,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,90,120,60,60),
No_1st_pref = c("59 (53)","89 (89)","28 (28)","39 (39)","111 (80)","8 (8)","117 (113)",
"41 (41)","41 (27)","43 (43)","32 (32)","21 (21)","67 (52)","132 (119)",
"78 (54)","45 (45)","84 (82)","41 (41)","38 (38)","18 (18)","47 (47)",
"22 (22)","45 (30)","101 (85)","47 (30)","29 (29)","44 (44)","69 (69)",
"125 (90)","14 (14)","31 (26)","23 (23)","17 (17)","119 (86)","30 (28)",
"14 (14)","36 (27)","21 (21)","32 (29)","54 (51)","28 (27)","17 (17)",
"58 (58)","34 (34)","30 (30)","114 (108)","78 (60)","49 (49)"),
No_2nd_pref = c("53 (6)","73 (8)","28 (1)","16 (1)","107 (9)","22 (6)","77 (6)","31 (5)",
"40 (3)","7 (0)","21 (3)","25 (0)","57 (8)","96 (1)","115 (6)","47 (8)",
"69 (7)","58 (4)","48 (6)","23 (1)","12 (4)","10 (0)","43 (0)","79 (5)",
"43 (0)","38 (11)","24 (1)","19 (5)","87 (0)","10 (3)","21 (3)","23 (5)",
"23 (5)","87 (4)","37 (2)","13 (0)","25 (2)","15 (1)","28 (1)","47 (8)",
"28 (2)","22 (3)","85 (13)","24 (2)","116 (11)","72 (10)","67 (0)","39 (2)"),
No_3rd_pref = c("49 (1)","103 (2)","35 (1)","14 (0)","74 (2)","19 (0)","60 (1)","33 (1)",
"53 (0)","9 (0)","10 (1)","35 (0)","38 (0)","75 (0)","81 (0)","54 (7)",
"76 (1)","38 (0)","49 (0)","39 (2)","13 (0)","12 (0)","32 (0)","26 (0)",
"33 (0)","59 (4)","7 (0)","23 (0)","75 (0)","10 (0)","41 (1)","11 (1)",
"14 (0)","60 (0)","25 (0)","10 (0)","31 (1)","12 (0)","24 (0)","45 (1)",
"36 (1)","23 (1)","82 (6)","21 (2)","102 (4)","71 (2)","54 (0)","18 (0)"),
Total = c("161 (60)","265 (99)","91 (30)","69 (40)","292 (91)","49 (14)","254 (120)",
"105 (47)","134 (30)","59 (43)","63 (36)","81 (21)","162 (60)","303 (120)",
"274 (60)","146 (60)","229 (90)","137 (45)","135 (44)","80 (21)","72 (51)",
"44 (22)","120 (30)","206 (90)","123 (30)","126 (44)","75 (45)","111 (74)",
"287 (90)","34 (17)","93 (30)","57 (29)","54 (22)","266 (90)","92 (30)",
"37 (14)","92 (30)","48 (22)","84 (30)","146 (60)","92 (30)","62 (21)",
"225 (77)","79 (38)","248 (45)","257 (120)","199 (60)","106 (51)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2020, extensions = 'Buttons', options = dt_opts)
```
## 2019
<https://www.brighton-hove.gov.uk/sites/default/files/schools/primary-allocation-fact-sheet-2019.pdf>
```{r}
primary_2019 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,90,30,120,60,30,60,60,30,60,120,60,60,90,90,60,30,90,60,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,90,120,90,60),
No_1st_pref = c("54 (54)","98 (98)","42 (42)","35 (35)","126 (86)","17 (17)","126 (117)",
"33 (33)","52 (28)","57 (57)","35 (35)","31 (30)","50 (50)","142 (119)",
"49 (44)","39 (39)","88 (83)","53 (53)","42 (42)","23 (22)","54 (54)",
"31 (31)","27 (27)","80 (80)","29 (26)","28 (28)","35 (35)","93 (90)",
"88 (87)","7 (7)","29 (28)","33 (29)","16 (16)","114 (86)","24 (24)",
"15 (15)","17 (17)","18 (18)","41 (30)","65 (56)","23 (23)","21 (21)",
"87 (81)","23 (23)","46 (46)","108 (108)","102 (87)","45 (45)"),
No_2nd_pref = c("49 (5)","72 (7)","33 (3)","11 (0)","101 (3)","19 (3)","80 (3)","27 (3)",
"47 (2)","7 (3)","20 (0)","18 (0)","54 (5)","139 (1)","95 (14)","38 (2)",
"77 (7)","50 (1)","63 (8)","31 (7)","14 (3)","17 (0)","40 (0)","86 (4)",
"52 (3)","47 (10)","22 (1)","12 (0)","72 (3)","14 (2)","32 (2)","14 (1)",
"7 (1)","80 (3)","31 (1)","24 (5)","20 (0)","18 (0)","39 (0)","58 (4)",
"24 (1)","20 (7)","65 (7)","20 (1)","127 (10)","68 (10)","69 (3)","39 (2)"),
No_3rd_pref = c("51 (1)","105 (1)","40 (0)","14 (0)","90 (1)","36 (1)","71 (0)","26 (1)",
"47 (0)","9 (0)","12 (0)","22 (0)","37 (0)","65 (0)","85 (2)","54 (3)",
"82 (0)","33 (0)","87 (2)","30 (1)","12 (0)","9 (0)","20 (0)","35 (0)",
"37 (1)","57 (8)","11 (0)","15 (0)","83 (0)","16 (0)","28 (0)","18 (0)",
"10 (0)","73 (1)","29 (0)","9 (0)","22 (0)","19 (1)","24 (0)","38 (0)",
"31 (1)","27 (1)","88 (2)","21 (0)","84 (0)","79 (0)","36 (0)","12 (1)"),
Total = c("154 (60)","275 (106)","115 (45)","60 (35)","317 (90)","72 (21)","277 (120)",
"86 (37)","146 (30)","72 (60)","67 (35)","72 (30)","141 (55)","346 (120)",
"229 (60)","131 (44)","247 (90)","136 (54)","192 (52)","84 (30)","80 (57)",
"57 (31)","87 (27)","201 (84)","118 (30)","132 (46)","68 (36)","120 (90)",
"243 (90)","37 (9)","89 (30)","65 (30)","34 (17)","267 (90)","84 (25)",
"48 (20)","60 (17)","55 (19)","104 (30)","161 (60)","78 (25)","68 (29)",
"240 (90)","64 (24)","257 (56)","255 (118)","207 (90)","96 (48)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2019, extensions = 'Buttons', options = dt_opts)
```
## 2018
<https://web.archive.org/web/2024/https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Primary%20Allocation%20Fact%20Sheet%202018.pdf>
```{r}
primary_2018 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,90,30,120,60,30,60,60,60,60,120,60,60,90,90,60,30,90,90,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,120,120,90,60),
No_1st_pref = c("46 (46)","86 (86)","27 (27)","28 (28)","93 (72)","15 (15)","119 (110)",
"47 (47)","35 (27)","38 (38)","32 (32)","22 (22)","76 (60)","127 (119)",
"54 (47)","41 (41)","98 (85)","67 (67)","36 (36)","35 (26)","63 (63)",
"29 (29)","21 (21)","76 (76)","47 (29)","38 (38)","51 (51)","83 (83)",
"150 (88)","11 (11)","22 (22)","24 (24)","17 (17)","135 (89)","22 (22)",
"15 (15)","25 (25)","29 (27)","31 (29)","45 (45)","24 (24)","23 (23)",
"82 (82)","25 (25)","47 (47)","130 (110)","78 (78)","49 (49)"),
No_2nd_pref = c("64 (10)","103 (3)","23 (2)","9 (0)","84 (14)","18 (1)","90 (9)","23 (0)",
"31 (2)","5 (0)","16 (0)","8 (1)","39 (0)","122 (1)","97 (13)","36 (0)",
"82 (5)","54 (10)","49 (8)","34 (4)","22 (12)","11 (0)","30 (2)","59 (0)",
"41 (1)","56 (16)","25 (2)","11 (2)","65 (2)","7 (1)","20 (1)","15 (3)",
"5 (0)","74 (1)","35 (0)","18 (2)","21 (1)","23 (1)","26 (1)","53 (6)",
"23 (0)","19 (3)","56 (7)","13 (0)","132 (30)","86 (10)","45 (1)","37 (1)"),
No_3rd_pref = c("41 (2)","112 (1)","41 (2)","14 (0)","99 (4)","26 (0)","72 (1)","30 (0)",
"47 (1)","6 (1)","6 (0)","9 (0)","28 (0)","63 (0)","82 (0)","43 (3)",
"71 (0)","50 (3)","63 (0)","30 (0)","13 (0)","5 (0)","16 (0)","37 (0)",
"36 (0)","68 (7)","12 (0)","18 (0)","76 (0)","10 (1)","17 (0)","11 (0)",
"7 (0)","47 (0)","29 (0)","14 (0)","16 (0)","21 (2)","14 (0)","50 (0)",
"29 (0)","15 (0)","71 (1)","23 (0)","112 (8)","87 (0)","47 (0)","22 (1)"),
Total = c("151 (58)","301 (90)","91 (31)","51 (28)","276 (90)","59 (16)","281 (120)",
"100 (47)","113 (30)","49 (39)","54 (32)","39 (23)","143 (60)","312 (120)",
"233 (60)","120 (44)","251 (90)","171 (80)","148 (44)","99 (30)","98 (75)",
"45 (29)","67 (23)","172 (76)","124 (30)","162 (61)","88 (53)","112 (85)",
"291 (90)","28 (13)","59 (23)","50 (27)","29 (17)","256 (90)","86 (22)",
"47 (17)","62 (26)","73 (30)","71 (30)","148 (51)","76 (24)","57 (26)",
"209 (90)","61 (25)","291 (85)","303 (120)","170 (79)","108 (51)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2018, extensions = 'Buttons', options = dt_opts)
```
## 2017
<https://web.archive.org/web/2022/https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Primary%20Allocation%20Fact%20Sheet%202017_0.pdf>
```{r}
primary_2017 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,90,30,120,60,30,60,60,60,60,120,60,60,90,90,60,30,90,90,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,120,120,90,60),
No_1st_pref = c("81 (58)","122 (117)","28 (28)","31 (31)","105 (77)","17 (17)","97 (97)",
"44 (44)","37 (26)","41 (41)","41 (41)","14 (14)","42 (42)","105 (105)",
"58 (46)","49 (49)","94 (82)","62 (62)","34 (34)","31 (27)","58 (58)",
"22 (22)","31 (29)","116 (88)","20 (20)","47 (46)","47 (47)","83 (83)",
"101 (86)","15 (15)","38 (30)","22 (22)","11 (11)","131 (87)","26 (26)",
"26 (26)","27 (27)","22 (22)","20 (20)","67 (60)","31 (27)","19 (19)",
"98 (85)","23 (23)","70 (70)","123 (110)","88 (87)","48 (48)"),
No_2nd_pref = c("54 (2)","77 (3)","18 (4)","7 (0)","76 (9)","19 (2)","94 (14)","26 (7)",
"41 (4)","11 (2)","16 (2)","6 (1)","44 (7)","118 (7)","102 (9)","44 (2)",
"90 (5)","56 (6)","47 (3)","33 (4)","13 (1)","6 (0)","32 (0)","81 (2)",
"45 (2)","58 (9)","29 (0)","11 (1)","63 (4)","18 (3)","18 (0)","16 (3)",
"8 (1)","81 (3)","41 (2)","11 (1)","9 (2)","10 (0)","19 (1)","47 (0)",
"19 (3)","21 (1)","60 (5)","11 (0)","128 (15)","86 (9)","87 (3)","33 (1)"),
No_3rd_pref = c("35 (0)","122 (0)","23 (1)","9 (0)","89 (4)","20 (0)","65 (2)","32 (5)",
"33 (0)","6 (1)","12 (0)","14 (0)","24 (1)","71 (1)","77 (5)","39 (3)",
"75 (3)","40 (2)","80 (0)","37 (0)","18 (0)","4 (0)","19 (1)","33 (0)",
"33 (1)","67 (5)","8 (0)","20 (0)","62 (0)","18 (0)","23 (0)","8 (1)",
"7 (0)","58 (0)","24 (0)","10 (0)","16 (0)","19 (0)","19 (0)","28 (0)",
"27 (0)","31 (0)","67 (0)","19 (1)","92 (3)","77 (1)","49 (0)","14 (0)"),
Total = c("170 (60)","321 (120)","69 (33)","47 (31)","270 (90)","56 (19)","256 (113)",
"102 (56)","111 (30)","58 (44)","69 (43)","34 (15)","110 (50)","294 (113)",
"237 (60)","132 (54)","259 (90)","158 (70)","161 (37)","101 (31)","89 (59)",
"32 (22)","82 (30)","230 (90)","98 (23)","172 (60)","84 (47)","114 (84)",
"226 (90)","51 (18)","79 (30)","46 (26)","26 (12)","270 (90)","91 (28)",
"47 (27)","52 (29)","51 (22)","58 (21)","142 (60)","77 (30)","71 (20)",
"225 (90)","53 (24)","290 (88)","286 (120)","224 (90)","95 (49)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2017, extensions = 'Buttons', options = dt_opts)
```
## 2016
<https://web.archive.org/web/2022/https://ww3.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Primary%20Allocation%20Fact%20sheet%202016%20-%20final.pdf>
```{r}
primary_2016 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Brunswick Primary","Carden Primary",
"Carlton Hill Primary","City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Downs Infant","Elm Grove Primary",
"Fairlight Primary","Goldstone Primary","Hangleton Primary","Hertford Primary",
"Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,90,60,120,60,30,60,60,60,60,120,60,60,90,90,60,30,90,90,30,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,120,120,90,60),
No_1st_pref = c("82 (60)","147 (118)","18 (18)","36 (36)","67 (67)","21 (21)","164 (118)",
"38 (38)","37 (25)","46 (46)","42 (42)","21 (21)","48 (48)","129 (114)",
"65 (49)","45 (45)","84 (81)","76 (76)","40 (40)","27 (22)","77 (77)",
"39 (39)","33 (29)","87 (86)","45 (29)","60 (47)","51 (51)","87 (87)",
"133 (89)","22 (22)","31 (28)","27 (27)","14 (14)","123 (88)","34 (29)",
"19 (19)","25 (25)","22 (22)","26 (26)","51 (51)","36 (29)","37 (30)",
"93 (83)","16 (16)","74 (73)","128 (109)","86 (83)","40 (40)"),
No_2nd_pref = c("65 (0)","110 (2)","16 (3)","12 (0)","47 (12)","34 (2)","84 (1)","20 (4)",
"50 (5)","3 (3)","15 (1)","16 (0)","52 (5)","133 (6)","82 (9)","43 (5)",
"78 (7)","59 (8)","43 (9)","42 (7)","20 (4)","10 (2)","29 (1)","67 (3)",
"33 (1)","74 (10)","15 (0)","17 (1)","74 (1)","19 (4)","23 (2)","7 (1)",
"5 (1)","85 (1)","48 (1)","11 (0)","12 (2)","17 (2)","21 (2)","43 (6)",
"27 (1)","21 (0)","76 (5)","9 (0)","167 (32)","90 (9)","77 (6)","36 (3)"),
No_3rd_pref = c("52 (0)","116 (0)","28 (0)","16 (0)","43 (2)","45 (4)","65 (1)","27 (0)",
"36 (0)","7 (3)","15 (0)","12 (0)","37 (4)","74 (0)","94 (2)","55 (3)",
"100 (2)","47 (1)","66 (2)","36 (1)","17 (0)","7 (0)","16 (0)","36 (1)",
"38 (0)","86 (3)","18 (2)","22 (1)","79 (0)","14 (2)","34 (0)","5 (0)",
"9 (0)","53 (1)","27 (0)","4 (2)","22 (1)","20 (1)","18 (1)","36 (1)",
"22 (0)","20 (0)","104 (2)","20 (1)","103 (15)","108 (2)","47 (1)","18 (1)"),
Total = c("199 (60)","373 (120)","62 (21)","64 (36)","157 (81)","100 (27)","313 (120)",
"85 (42)","123 (30)","56 (52)","72 (43)","49 (21)","137 (57)","336 (120)",
"241 (60)","143 (53)","262 (90)","182 (85)","149 (51)","105 (30)","114 (81)",
"56 (41)","78 (30)","190 (90)","116 (30)","220 (60)","84 (53)","126 (89)",
"286 (90)","55 (28)","88 (30)","39 (28)","28 (15)","261 (90)","109 (30)",
"34 (21)","59 (28)","59 (25)","65 (29)","130 (58)","85 (30)","78 (30)",
"273 (90)","45 (17)","344 (120)","326 (120)","210 (90)","94 (44)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2016, extensions = 'Buttons', options = dt_opts)
```
## 2015
<https://web.archive.org/web/2015/http://www.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Primary%20Factsheet%20-%20allocation%20details%20with%20Woodingdean.pdf>
```{r}
primary_2015 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Carden Primary","Carlton Hill Primary",
"City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Davigdor Infant","Downs Infant",
"Elm Grove Primary","Fairlight Primary","Goldstone Primary","Hangleton Infant",
"Hertford Infant","Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Patcham Infant","Peter Gladwin Primary","Queens Park Primary",
"Rudyard Kipling Primary","Saltdean Primary","St Andrew's CE Primary",
"St Bartholomew's CE Primary","St Bernadette's Catholic Primary",
"St John The Baptist Catholic Primary","St Joseph's Catholic Primary",
"St Luke's Primary","St Margaret's CE Primary","St Mark's CE Primary",
"St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,60,60,60,30,60,60,60,60,120,120,60,60,90,90,60,30,90,90,90,30,
60,60,90,90,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,120,120,90,60),
No_1st_pref = c("58 (57)","144 (115)","21 (21)","53 (53)","38 (38)","24 (24)","37 (37)",
"29 (27)","43 (43)","43 (43)","22 (22)","50 (50)","140 (116)","138 (114)",
"66 (49)","64 (53)","88 (87)","82 (82)","50 (49)","30 (23)","70 (70)",
"28 (28)","97 (85)","42 (30)","62 (50)","53 (53)","91 (89)","102 (89)",
"18 (18)","32 (28)","31 (25)","16 (16)","129 (87)","23 (23)","18 (18)",
"30 (28)","21 (21)","35 (28)","57 (56)","40 (28)","43 (28)","97 (85)",
"23 (23)","87 (86)","147 (117)","109 (90)","60 (58)"),
No_2nd_pref = c("44 (2)","113 (5)","16 (7)","7 (1)","16 (3)","37 (5)","18 (3)","36 (2)",
"7 (1)","11 (1)","13 (3)","39 (8)","99 (4)","130 (6)","92 (9)","28 (5)",
"79 (2)","55 (6)","45 (5)","42 (3)","17 (4)","9 (3)","72 (4)","36 (1)",
"68 (9)","21 (1)","17 (1)","75 (1)","17 (8)","23 (1)","7 (2)","8 (1)",
"92 (1)","51 (0)","15 (4)","13 (1)","14 (1)","20 (2)","44 (3)","28 (2)",
"33 (2)","87 (5)","18 (0)","156 (26)","96 (2)","89 (0)","34 (1)"),
No_3rd_pref = c("35 (1)","122 (0)","26 (1)","10 (2)","36 (3)","45 (2)","31 (1)","46 (1)",
"7 (0)","18 (2)","16 (1)","31 (1)","92 (0)","70 (0)","62 (2)","49 (2)",
"81 (1)","45 (0)","76 (6)","48 (4)","12 (0)","10 (0)","36 (1)","29 (0)",
"87 (1)","16 (1)","17 (0)","66 (0)","20 (2)","17 (1)","11 (3)","10 (0)",
"50 (2)","21 (0)","13 (3)","24 (1)","12 (2)","13 (0)","26 (1)","25 (0)",
"32 (0)","98 (0)","16 (1)","103 (8)","99 (1)","43 (0)","16 (1)"),
Total = c("137 (60)","379 (120)","63 (29)","70 (56)","90 (44)","106 (31)","86 (41)",
"111 (30)","57 (44)","72 (46)","51 (26)","120 (59)","331 (120)","338 (120)",
"220 (60)","141 (60)","248 (90)","182 (88)","171 (60)","120 (30)","99 (74)",
"47 (31)","205 (90)","107 (31)","217 (60)","90 (55)","125 (90)","243 (90)",
"55 (28)","72 (30)","49 (30)","34 (17)","271 (90)","95 (23)","46 (25)",
"67 (30)","47 (24)","68 (30)","127 (60)","93 (30)","108 (30)","282 (90)",
"57 (24)","346 (120)","342 (120)","241 (90)","110 (60)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2015, extensions = 'Buttons', options = dt_opts)
```
## 2014
<https://web.archive.org/web/2014/http://www.brighton-hove.gov.uk/sites/brighton-hove.gov.uk/files/Primary%20Allocation%20Fact%20Sheet%202014_0.pdf>
```{r}
primary_2014 <- data.frame(
School = c("Aldrington CE Primary","Balfour Primary","Benfield Primary","Bevendean Primary",
"Bilingual Primary","Brackenbury Primary","Carden Primary","Carlton Hill Primary",
"City Academy Whitehawk","Coldean Primary","Coombe Road Primary",
"Cottesmore St Mary Catholic Primary","Davigdor Infant","Downs Infant",
"Elm Grove Primary","Fairlight Primary","Goldstone Primary","Hangleton Infant",
"Hertford Infant","Middle Street Primary","Mile Oak Primary","Moulsecoomb Primary",
"Our Lady of Lourdes Catholic Primary","Patcham Infant","Peter Gladwin Primary",
"Queens Park Primary","Rudyard Kipling Primary","Saltdean Primary",
"St Andrew's CE Primary","St Bartholomew's CE Primary",
"St Bernadette's Catholic Primary","St John The Baptist Catholic Primary",
"St Joseph's Catholic Primary","St Luke's Primary","St Margaret's CE Primary",
"St Mark's CE Primary","St Martin's CE Primary","St Mary Magdalen Catholic Primary",
"St Mary's Catholic Primary","St Nicolas CE Primary","St Paul's CE Primary",
"St Peter's Community Primary","Stanford Infant","West Blatchington Primary",
"West Hove Infant, Holland Road","West Hove Infant, Portland Road",
"Westdene Primary","Woodingdean Primary"),
PAN = c(60,120,60,60,60,60,60,30,60,60,60,60,150,120,60,60,90,90,60,30,90,90,30,90,30,
60,60,90,60,30,30,30,30,90,30,30,30,30,30,60,30,30,90,60,120,120,90,60),
No_1st_pref = c("49 (46)","131 (113)","28 (28)","46 (46)","61 (51)","34 (34)","35 (35)",
"48 (29)","25 (25)","55 (55)","17 (17)","76 (59)","142 (130)","145 (113)",
"66 (53)","48 (48)","116 (86)","84 (73)","48 (47)","30 (26)","62 (62)",
"25 (25)","28 (27)","95 (80)","53 (29)","49 (38)","48 (48)","82 (82)",
"121 (60)","23 (22)","42 (31)","38 (28)","15 (15)","125 (88)","32 (30)",
"26 (24)","31 (29)","37 (29)","18 (18)","32 (32)","32 (30)","56 (29)",
"100 (87)","21 (21)","82 (74)","147 (104)","109 (88)","48 (48)"),
No_2nd_pref = c("62 (9)","119 (7)","14 (4)","6 (0)","28 (2)","47 (14)","14 (3)","33 (0)",
"9 (5)","13 (1)","11 (1)","41 (1)","120 (15)","140 (6)","81 (7)","31 (8)",
"74 (3)","84 (15)","38 (6)","31 (4)","13 (7)","6 (0)","36 (2)","55 (7)",
"57 (1)","71 (16)","21 (0)","11 (0)","77 (0)","22 (6)","20 (0)","10 (0)",
"10 (4)","93 (2)","46 (0)","13 (4)","14 (1)","16 (1)","15 (5)","43 (12)",
"26 (0)","35 (1)","89 (3)","16 (3)","158 (34)","103 (12)","90 (1)","23 (4)"),
No_3rd_pref = c("43 (5)","123 (0)","21 (2)","11 (0)","37 (7)","62 (11)","19 (4)","48 (1)",
"1 (1)","9 (1)","15 (0)","36 (0)","96 (5)","57 (1)","65 (0)","47 (4)",
"87 (1)","60 (2)","71 (7)","43 (0)","17 (1)","11 (0)","21 (1)","36 (3)",
"20 (0)","83 (6)","15 (0)","21 (0)","70 (0)","20 (2)","27 (0)","10 (2)",
"8 (0)","38 (0)","19 (0)","11 (2)","21 (0)","20 (0)","22 (5)","31 (5)",
"17 (0)","29 (0)","92 (0)","26 (2)","100 (12)","106 (4)","41 (1)","36 (4)"),
Total = c("154 (60)","373 (120)","63 (34)","63 (46)","126 (60)","143 (59)","68 (42)",
"130 (30)","35 (31)","77 (57)","43 (18)","153 (60)","358 (150)","342 (120)",
"212 (60)","126 (60)","278 (90)","228 (90)","157 (60)","104 (30)","92 (70)",
"42 (25)","85 (30)","186 (90)","130 (30)","203 (60)","84 (48)","114 (82)",
"268 (60)","65 (30)","89 (31)","58 (30)","33 (19)","256 (90)","97 (30)",
"50 (30)","66 (30)","73 (30)","55 (28)","106 (49)","75 (30)","120 (30)",
"282 (90)","63 (26)","340 (120)","356 (120)","240 (90)","107 (56)"),
stringsAsFactors = FALSE
) |>
parse_prefs(c("No_1st_pref", "No_2nd_pref", "No_3rd_pref", "Total"))
datatable(primary_2014, extensions = 'Buttons', options = dt_opts)
```
:::
## Data Prior to 2014
A 2013 **Primary** allocation factsheet could not be recovered from either the live council site or the Internet Archive. A 2013 *Junior* factsheet exists, and 2010/2011 primary booklets are archived as Word documents — they may be digitised in future.
## Combined Primary Data 2014–2026
```{r}
primary_frames <- list(
`2014` = primary_2014, `2015` = primary_2015, `2016` = primary_2016,
`2017` = primary_2017, `2018` = primary_2018, `2019` = primary_2019,
`2020` = primary_2020, `2021` = primary_2021, `2022` = primary_2022,
`2023` = primary_2023, `2024` = primary_2024, `2025` = primary_2025,
`2026` = primary_2026
)
primary_all <- bind_rows(lapply(names(primary_frames), function(y) {
primary_frames[[y]] %>% mutate(Year = as.integer(y))
}))
# Normalise naming so a single school can be tracked over time regardless of
# infant/primary rebranding or RC -> Catholic rename.
primary_all <- primary_all %>%
mutate(School = case_when(
School == "Hangleton Infant" ~ "Hangleton Primary",
School == "Hertford Infant" ~ "Hertford Primary",
School == "Cottesmore St Mary RC Primary" ~ "Cottesmore St Mary Catholic Primary",
School == "Our Lady of Lourdes RC Primary" ~ "Our Lady of Lourdes Catholic Primary",
School == "St Bernadette's RC Primary" ~ "St Bernadette's Catholic Primary",
School == "St John The Baptist RC Primary" ~ "St John The Baptist Catholic Primary",
School == "St Joseph's RC Primary" ~ "St Joseph's Catholic Primary",
School == "St Mary Magdalen RC Primary" ~ "St Mary Magdalen Catholic Primary",
School == "St Mary's RC Primary" ~ "St Mary's Catholic Primary",
School == "West Hove Connaught Road" ~ "West Hove Infant, Holland Road",
TRUE ~ School
))
datatable(
primary_all %>% select(Year, School, PAN, everything()),
extensions = 'Buttons',
options = dt_opts,
filter = "top"
)
```
```{r schools-catchments, include=FALSE}
# ---- Shared lookup: school locations + geographic catchment mapping ----
# Used by the primary by-catchment tabsets below, the by-catchment cohort chart,
# and the proportional-circle maps further down. Secondary faith schools (Kings,
# Cardinal Newman) are overridden to "Religious schools" because they admit
# city-wide and sit outside the geographic framework. Catholic primaries are
# also grouped as "Religious schools" so they are compared against Cardinal
# Newman-style secondary demand rather than against their geographic catchment.
library(sf)
school_locs <- tibble::tribble(
~School, ~Phase, ~URN, ~lat, ~lon,
# Secondary (10 schools)
"Blatchington Mill", "Secondary", 114606L, 50.845298, -0.184029,
"Brighton Aldridge Community Academy", "Secondary", 136164L, 50.858017, -0.091234,
"Cardinal Newman", "Secondary", 114611L, 50.836811, -0.158368,
"Dorothy Stringer", "Secondary", 114580L, 50.848948, -0.143414,
"Hove Park", "Secondary", 114607L, 50.840867, -0.179613,
"Kings School", "Secondary", 139409L, 50.853022, -0.192069,
"Longhill High", "Secondary", 114581L, 50.818999, -0.067270,
"Patcham High", "Secondary", 114608L, 50.862195, -0.143940,
"Portslade Aldridge Community Academy", "Secondary", 137063L, 50.850644, -0.225774,
"Varndean", "Secondary", 114579L, 50.850296, -0.137138,
# Primary (open)
"Aldrington CE Primary", "Primary", 114555L, 50.844506, -0.178606,
"Balfour Primary", "Primary", 114382L, 50.847735, -0.138348,
"Benfield Primary", "Primary", 151063L, 50.837928, -0.207026,
"Bevendean Primary", "Primary", 114485L, 50.841871, -0.099293,
"Bilingual Primary", "Primary", 138261L, 50.844225, -0.175804,
"Brackenbury Primary", "Primary", 114413L, 50.840626, -0.216424,
"Brunswick Primary", "Primary", 114446L, 50.831152, -0.158236,
"Carden Primary", "Primary", 131789L, 50.863304, -0.128889,
"Carlton Hill Primary", "Primary", 114381L, 50.824652, -0.132167,
"City Academy Whitehawk", "Primary", 139677L, 50.825445, -0.107386,
"Coldean Primary", "Primary", 114384L, 50.865344, -0.112621,
"Coombe Road Primary", "Primary", 114365L, 50.840172, -0.117685,
"Cottesmore St Mary Catholic Primary", "Primary", 114567L, 50.837319, -0.158093,
"Downs Infant", "Primary", 114367L, 50.837970, -0.133397,
"Elm Grove Primary", "Primary", 114477L, 50.831581, -0.121453,
"Fairlight Primary", "Primary", 114487L, 50.834574, -0.125792,
"Goldstone Primary", "Primary", 114398L, 50.841566, -0.192185,
"Hangleton Primary", "Primary", 151062L, 50.846678, -0.195922,
"Hertford Primary", "Primary", 114368L, 50.846789, -0.124536,
"Middle Street Primary", "Primary", 114369L, 50.821362, -0.142961,
"Mile Oak Primary", "Primary", 114430L, 50.853978, -0.227977,
"Moulsecoomb Primary", "Primary", 147680L, 50.849467, -0.110705,
"Our Lady of Lourdes Catholic Primary", "Primary", 114544L, 50.806289, -0.058668,
"Patcham Infant", "Primary", 114373L, 50.861340, -0.147043,
"Peter Gladwin Primary", "Primary", 114443L, 50.843682, -0.220215,
"Queens Park Primary", "Primary", 114478L, 50.823015, -0.125659,
"Rudyard Kipling Primary", "Primary", 114486L, 50.833830, -0.063732,
"Saltdean Primary", "Primary", 114479L, 50.808777, -0.038083,
"St Andrew's CE Primary", "Primary", 114556L, 50.830581, -0.174575,
"St Bernadette's Catholic Primary", "Primary", 114546L, 50.846703, -0.150421,
"St John The Baptist Catholic Primary", "Primary", 114540L, 50.822669, -0.115861,
"St Joseph's Catholic Primary", "Primary", 114542L, 50.842027, -0.126743,
"St Luke's Primary", "Primary", 114374L, 50.827216, -0.120137,
"St Margaret's CE Primary", "Primary", 114537L, 50.806308, -0.055801,
"St Mark's CE Primary", "Primary", 114545L, 50.819405, -0.111237,
"St Martin's CE Primary", "Primary", 114539L, 50.835193, -0.123481,
"St Mary Magdalen Catholic Primary", "Primary", 114541L, 50.825073, -0.149217,
"St Mary's Catholic Primary", "Primary", 114570L, 50.832265, -0.213633,
"St Nicolas CE Primary", "Primary", 114560L, 50.840626, -0.216424,
"St Paul's CE Primary", "Primary", 114543L, 50.826597, -0.144358,
"Stanford Infant", "Primary", 114377L, 50.838582, -0.152120,
"West Blatchington Primary", "Primary", 149963L, 50.853022, -0.192069,
"West Hove Infant, Holland Road", "Primary", 114428L, 50.826834, -0.161984,
"West Hove Infant, Portland Road", "Primary", 114428L, 50.834027, -0.189693,
"Westdene Primary", "Primary", 114380L, 50.860040, -0.161759,
"Woodingdean Primary", "Primary", 114480L, 50.836176, -0.078079,
# Closed/renamed schools retained for historic series
"Davigdor Infant", "Primary", 114397L, 50.828776, -0.159267,
"St Bartholomew's CE Primary", "Primary", 114538L, 50.830378, -0.137278,
"St Peter's Community Primary", "Primary", 114411L, 50.831506, -0.215153,
"Hangleton Infant", "Primary", 151062L, 50.846678, -0.195922,
"Hertford Infant", "Primary", 114368L, 50.846789, -0.124536
)
optionZ <- st_read(here::here("data", "optionZ_Mar25.geojson"), quiet = TRUE) |>
st_transform(4326)
# Geographic catchment from point-in-polygon, with faith-school overrides.
schools_sf <- school_locs |>
sf::st_as_sf(coords = c("lon", "lat"), crs = 4326, remove = FALSE)
schools_with_catchment <- sf::st_join(
schools_sf,
optionZ |> dplyr::select(geo_catchment = catchment),
join = sf::st_within
) |>
sf::st_drop_geometry() |>
dplyr::mutate(
is_faith_sec = Phase == "Secondary" &
School %in% c("Kings School", "Cardinal Newman"),
is_catholic = Phase == "Primary" &
grepl("Catholic Primary", School, fixed = TRUE),
CatchmentGroup = dplyr::case_when(
is_faith_sec ~ "Religious schools",
is_catholic ~ "Religious schools",
geo_catchment == "BACA" ~ "BACA",
geo_catchment == "HoveBlatchington" ~ "Hove Park / Blatchington Mill",
geo_catchment == "Longhill" ~ "Longhill",
geo_catchment == "PACA" ~ "PACA",
geo_catchment == "Patcham" ~ "Patcham",
geo_catchment == "VarndeanStringer" ~ "Varndean / Dorothy Stringer",
TRUE ~ NA_character_
)
) |>
dplyr::select(School, Phase, CatchmentGroup)
```
```{r primary-catchment-helpers, include=FALSE}
# ---- Helpers for the by-catchment primary tabsets -------------------------
# Each catchment gets a distinct base hue; schools within a catchment get
# a shaded ramp from that hue towards a pale grey, giving every school a
# unique line colour within its tab while keeping catchments visually
# distinct across tabs.
primary_catch_base_colors <- c(
"BACA" = "forestgreen",
"Hove Park / Blatchington Mill" = "steelblue",
"Longhill" = "firebrick",
"PACA" = "darkorange",
"Patcham" = "purple",
"Varndean / Dorothy Stringer" = "goldenrod",
"Religious schools" = "mediumorchid"
)
# Tab display order (match the catchment colour vector above)
primary_catch_order <- names(primary_catch_base_colors)
# Which primaries fall in each catchment (based on the shared lookup above).
# Filter to only schools that actually appear in the primary_all data so empty
# tabs don't appear in the output.
primary_catch_schools <- schools_with_catchment %>%
dplyr::filter(Phase == "Primary", !is.na(CatchmentGroup)) %>%
dplyr::filter(School %in% unique(primary_all$School)) %>%
dplyr::distinct(School, CatchmentGroup)
# Build a named colour vector (school -> hex) for a given catchment.
primary_catch_palette <- function(catch) {
base <- unname(primary_catch_base_colors[catch])
schs <- primary_catch_schools %>%
dplyr::filter(CatchmentGroup == catch) %>%
dplyr::arrange(School) %>%
dplyr::pull(School)
n <- length(schs)
if (n == 0) return(character(0))
# Ramp from base colour to a pale neutral so each school is distinguishable.
pal <- grDevices::colorRampPalette(c(base, "grey75"))(max(n, 2))[seq_len(n)]
stats::setNames(pal, schs)
}
# Generic plot builder — filters primary_all to one catchment and draws a
# ggplotly with each school in its own shade.
plot_primary_by_catchment <- function(catch, metric = c("Total", "FillPct", "No_1st_pref_offer")) {
metric <- match.arg(metric)
pal <- primary_catch_palette(catch)
if (length(pal) == 0) {
return(htmltools::tags$p(paste0("No primary schools mapped to ", catch, ".")))
}
dat <- primary_all %>%
dplyr::filter(School %in% names(pal)) %>%
dplyr::mutate(School = factor(School, levels = names(pal)))
# Compute y-value and tooltip text as explicit columns so aes() stays simple.
if (metric == "Total") {
dat <- dplyr::mutate(dat,
y_val = Total,
tt = paste0(School, "<br>Year: ", Year,
"<br>Applications: ", Total,
"<br>Offers: ", Total_offer,
"<br>PAN: ", PAN))
ylab <- "Total Applications"
title <- paste0("Total Applications \u2014 ", catch)
} else if (metric == "FillPct") {
dat <- dplyr::mutate(dat,
y_val = 100 * Total_offer / PAN,
tt = paste0(School, "<br>Year: ", Year,
"<br>Offers: ", Total_offer, " / PAN ", PAN,
"<br>Fill: ", round(100 * Total_offer / PAN, 1), "%"))
ylab <- "Offers \u00f7 PAN (%)"
title <- paste0("Offers as % of PAN \u2014 ", catch)
} else { # No_1st_pref_offer
dat <- dplyr::mutate(dat,
y_val = No_1st_pref_offer,
tt = paste0(School, "<br>Year: ", Year,
"<br>1st-pref offers: ", No_1st_pref_offer,
" / 1st-pref applications: ", No_1st_pref))
ylab <- "1st-preference Offers"
title <- paste0("1st-Preference Offers \u2014 ", catch)
}
p <- ggplot(dat,
aes(x = Year, y = y_val, color = School, group = School, text = tt)) +
geom_line() +
geom_point(size = 0.9) +
scale_color_manual(values = pal) +
scale_x_continuous(breaks = seq(2014, 2026, 2)) +
labs(title = title, x = "Year", y = ylab, color = NULL) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.title = element_blank())
if (metric == "FillPct") {
p <- p + geom_hline(yintercept = 100, linetype = "dashed", color = "grey40")
}
plotly::ggplotly(p, tooltip = "text") %>%
plotly::layout(legend = list(orientation = "v"))
}
```
## Interactive Time Series — Primary
The full primary-school series contains \~46 schools, so a single plot is unreadable. Below, each chart is split into tabs by the **secondary catchment** each primary sits within (these catchments don't formally govern primary admissions, but they're a useful geographic grouping — and they match how the cohort-aging projection later in this document treats primaries). Catholic primaries are grouped together as "Religious schools", mirroring how Kings and Cardinal Newman are handled on the secondary side. Within each tab, schools are given distinct shades of the catchment's base colour.
### Total Applications by School
::: panel-tabset
#### BACA
```{r, fig.height=6}
plot_primary_by_catchment("BACA", "Total")
```
#### Hove Park / Blatchington Mill
```{r, fig.height=6}
plot_primary_by_catchment("Hove Park / Blatchington Mill", "Total")
```
#### Longhill
```{r, fig.height=6}
plot_primary_by_catchment("Longhill", "Total")
```
#### PACA
```{r, fig.height=6}
plot_primary_by_catchment("PACA", "Total")
```
#### Patcham
```{r, fig.height=6}
plot_primary_by_catchment("Patcham", "Total")
```
#### Varndean / Dorothy Stringer
```{r, fig.height=6}
plot_primary_by_catchment("Varndean / Dorothy Stringer", "Total")
```
#### Religious schools
```{r, fig.height=6}
plot_primary_by_catchment("Religious schools", "Total")
```
:::
### Offers vs PAN — How Full Is Each School?
::: panel-tabset
#### BACA
```{r, fig.height=6}
plot_primary_by_catchment("BACA", "FillPct")
```
#### Hove Park / Blatchington Mill
```{r, fig.height=6}
plot_primary_by_catchment("Hove Park / Blatchington Mill", "FillPct")
```
#### Longhill
```{r, fig.height=6}
plot_primary_by_catchment("Longhill", "FillPct")
```
#### PACA
```{r, fig.height=6}
plot_primary_by_catchment("PACA", "FillPct")
```
#### Patcham
```{r, fig.height=6}
plot_primary_by_catchment("Patcham", "FillPct")
```
#### Varndean / Dorothy Stringer
```{r, fig.height=6}
plot_primary_by_catchment("Varndean / Dorothy Stringer", "FillPct")
```
#### Religious schools
```{r, fig.height=6}
plot_primary_by_catchment("Religious schools", "FillPct")
```
:::
### 1st-Preference Offers — Were Families Getting Their First Choice?
::: panel-tabset
#### BACA
```{r, fig.height=6}
plot_primary_by_catchment("BACA", "No_1st_pref_offer")
```
#### Hove Park / Blatchington Mill
```{r, fig.height=6}
plot_primary_by_catchment("Hove Park / Blatchington Mill", "No_1st_pref_offer")
```
#### Longhill
```{r, fig.height=6}
plot_primary_by_catchment("Longhill", "No_1st_pref_offer")
```
#### PACA
```{r, fig.height=6}
plot_primary_by_catchment("PACA", "No_1st_pref_offer")
```
#### Patcham
```{r, fig.height=6}
plot_primary_by_catchment("Patcham", "No_1st_pref_offer")
```
#### Varndean / Dorothy Stringer
```{r, fig.height=6}
plot_primary_by_catchment("Varndean / Dorothy Stringer", "No_1st_pref_offer")
```
#### Religious schools
```{r, fig.height=6}
plot_primary_by_catchment("Religious schools", "No_1st_pref_offer")
```
:::
### City-wide Aggregate
```{r}
primary_city <- primary_all %>%
group_by(Year) %>%
summarise(
Applications_1st = sum(No_1st_pref, na.rm = TRUE),
Offers_1st = sum(No_1st_pref_offer, na.rm = TRUE),
Offers_Total = sum(Total_offer, na.rm = TRUE),
PAN_Total = sum(PAN, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(FirstPrefRate = 100 * Offers_1st / Applications_1st,
FillRate = 100 * Offers_Total / PAN_Total)
p_city <- ggplot(primary_city, aes(x = Year)) +
geom_line(aes(y = FirstPrefRate, color = "1st-pref success rate",
text = paste0("Year: ", Year,
"<br>1st-pref success: ", round(FirstPrefRate, 1), "%")),
group = 1) +
geom_point(aes(y = FirstPrefRate, color = "1st-pref success rate",
text = paste0("Year: ", Year,
"<br>1st-pref success: ", round(FirstPrefRate, 1), "%"))) +
geom_line(aes(y = FillRate, color = "City-wide fill rate (offers/PAN)",
text = paste0("Year: ", Year,
"<br>Fill rate: ", round(FillRate, 1), "%")),
group = 1) +
geom_point(aes(y = FillRate, color = "City-wide fill rate (offers/PAN)",
text = paste0("Year: ", Year,
"<br>Fill rate: ", round(FillRate, 1), "%"))) +
scale_x_continuous(breaks = seq(2014, 2026, 1)) +
labs(title = "City-wide Primary Admissions Indicators",
x = "Year", y = "Percentage", color = NULL) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p_city, tooltip = "text")
```
### Oversubscribed vs Undersubscribed Schools, 2026
```{r}
latest <- primary_all %>% filter(Year == 2026) %>%
mutate(Balance = No_1st_pref - PAN,
Status = ifelse(Balance > 0, "Oversubscribed (1st prefs > PAN)",
"Undersubscribed"))
p_bal <- ggplot(latest,
aes(x = reorder(School, Balance), y = Balance, fill = Status,
text = paste0(School, "<br>1st preferences: ", No_1st_pref,
"<br>PAN: ", PAN,
"<br>Balance: ", Balance))) +
geom_col() +
coord_flip() +
labs(title = "2026 Reception: 1st-Preference Applications − PAN",
x = NULL, y = "Applications minus PAN") +
theme_minimal()
ggplotly(p_bal, tooltip = "text")
```
The map below shows the same over- / under-subscription picture spatially, with a year slider so you can step back through earlier admission rounds. Red circles are oversubscribed primaries (1st-preference applications above PAN); teal circles are undersubscribed (1st-preference applications below PAN). Circle area is proportional to the *magnitude* of the gap, so a big red circle is a heavily oversubscribed school in that year and a big teal circle is a heavily undersubscribed one.
```{r}
library(leaflet)
library(htmltools)
library(htmlwidgets)
library(jsonlite)
balance_map_data <- primary_all %>%
dplyr::filter(School != "Total",
!is.na(No_1st_pref), !is.na(PAN)) %>%
dplyr::mutate(Balance = No_1st_pref - PAN) %>%
dplyr::left_join(school_locs, by = "School") %>%
dplyr::filter(!is.na(lat), !is.na(lon))
years_bal <- sort(unique(balance_map_data$Year))
default_bal <- max(years_bal)
d_json_bal <- balance_map_data %>%
dplyr::transmute(
Year = as.integer(Year),
School,
lat, lon,
PAN,
No_1st_pref,
Balance
)
data_json_bal <- jsonlite::toJSON(d_json_bal, na = "null", dataframe = "rows")
m_bal <- leaflet(width = "100%", height = 560) |>
addProviderTiles(providers$CartoDB.Positron) |>
setView(lng = -0.155, lat = 50.835, zoom = 12)
data_tag_bal <- htmltools::tags$script(
type = "application/json",
id = "data_bal",
htmltools::HTML(as.character(data_json_bal))
)
js_bal <- "
function(el, x) {
var map = this;
var all = JSON.parse(document.getElementById('data_bal').textContent);
var years = __YEARS__;
var defaultYear = __DEFAULT_YEAR__;
var colourOver = '#e31a1c';
var colourUnder = '#1b9e77';
var radiusK = 0.85;
var radiusExp = 0.72;
var minRadius = 3;
var markers = L.layerGroup().addTo(map);
var legCtrl = L.control({position: 'bottomleft'});
var legDiv;
legCtrl.onAdd = function() {
legDiv = L.DomUtil.create('div');
legDiv.style.cssText = 'background:white;padding:8px 12px;' +
'border-radius:5px;box-shadow:0 1px 5px rgba(0,0,0,.25);' +
'font:12px sans-serif;max-width:260px;';
return legDiv;
};
legCtrl.addTo(map);
function fmt(v) {
if (v === null || v === undefined || isNaN(v)) return '\u2013';
return Number(v).toLocaleString();
}
function setLegend(year, nOver, nUnder) {
if (!legDiv) return;
legDiv.innerHTML =
'<strong>1st-pref apps − PAN — ' + year + '</strong><br>' +
'<span style=\"display:inline-block;width:12px;height:12px;background:' + colourOver +
';border-radius:50%;opacity:0.55;margin-right:4px;\"></span>' +
'Oversubscribed (' + nOver + ')<br>' +
'<span style=\"display:inline-block;width:12px;height:12px;background:' + colourUnder +
';border-radius:50%;opacity:0.55;margin-right:4px;\"></span>' +
'Undersubscribed (' + nUnder + ')<br>' +
'<em style=\"font-size:11px;color:#666\">Circle area \u221d |1st-pref apps \u2212 PAN|</em>';
}
function popup(d) {
var status = d.Balance > 0 ? 'Oversubscribed' :
(d.Balance < 0 ? 'Undersubscribed' : 'At capacity');
var bStr = d.Balance > 0 ? ('+' + fmt(d.Balance)) : fmt(d.Balance);
return '<b>' + d.School + '</b><br>Year: ' + d.Year + '<br>' +
'PAN: ' + fmt(d.PAN) + '<br>' +
'1st-pref applications: ' + fmt(d.No_1st_pref) + '<br>' +
'Balance: <b>' + bStr + '</b><br>' +
'<em>' + status + '</em>';
}
function draw(year) {
markers.clearLayers();
var rows = all.filter(function(d){ return String(d.Year) === String(year); });
var nOver = 0, nUnder = 0;
rows.forEach(function(d) {
if (d.lat == null || d.lon == null) return;
if (d.Balance == null || isNaN(d.Balance)) return;
var mag = Math.abs(d.Balance);
var r = mag <= 0 ? minRadius : Math.pow(mag, radiusExp) * radiusK;
if (r < minRadius) r = minRadius;
var colour = d.Balance >= 0 ? colourOver : colourUnder;
if (d.Balance > 0) nOver++; else if (d.Balance < 0) nUnder++;
L.circleMarker([d.lat, d.lon], {
radius: r,
fillColor: colour,
fillOpacity: 0.55,
color: colour,
weight: 1.3
})
.bindPopup(popup(d))
.bindTooltip(d.School)
.addTo(markers);
});
setLegend(year, nOver, nUnder);
}
var wrap = document.createElement('div');
wrap.style.cssText = 'margin:8px 0 12px 0;font:14px sans-serif;' +
'display:flex;align-items:center;gap:12px;flex-wrap:wrap;';
var lbl = document.createElement('label');
lbl.innerHTML = '<strong>Year:</strong>';
wrap.appendChild(lbl);
var yMin = years[0];
var yMax = years[years.length - 1];
var sld = document.createElement('input');
sld.type = 'range';
sld.min = String(yMin);
sld.max = String(yMax);
sld.step = '1';
sld.value = String(defaultYear);
sld.style.cssText = 'flex:1;min-width:240px;max-width:520px;' +
'accent-color:' + colourOver + ';';
wrap.appendChild(sld);
var val = document.createElement('span');
val.style.cssText = 'font-weight:bold;min-width:3.5em;text-align:right;';
val.textContent = String(defaultYear);
wrap.appendChild(val);
var ends = document.createElement('div');
ends.style.cssText = 'width:100%;display:flex;justify-content:space-between;' +
'font-size:11px;color:#666;margin-top:-4px;';
var endMin = document.createElement('span'); endMin.textContent = String(yMin);
var endMax = document.createElement('span'); endMax.textContent = String(yMax);
ends.appendChild(endMin);
ends.appendChild(endMax);
el.parentNode.insertBefore(wrap, el);
el.parentNode.insertBefore(ends, el);
function onSlide() {
var y = +sld.value;
val.textContent = String(y);
draw(y);
}
sld.addEventListener('input', onSlide);
sld.addEventListener('change', onSlide);
draw(defaultYear);
}
"
js_bal <- gsub("__YEARS__", as.character(jsonlite::toJSON(years_bal)), js_bal, fixed = TRUE)
js_bal <- gsub("__DEFAULT_YEAR__", as.character(default_bal), js_bal, fixed = TRUE)
m_bal <- m_bal |> htmlwidgets::onRender(js_bal)
htmltools::browsable(htmltools::tagList(data_tag_bal, m_bal))
```
# City-Wide State-Sector Trends
The school-by-school time series above make it hard to see the overall size of the state-sector cohort. The two charts here roll up the same offer data to the whole city (and then to the main secondary catchments) so the scale of demographic change — and how it lands across catchments — is visible.
## Total Offers Made Across the City (Primary + Secondary)
```{r}
# Sum offers across all schools per year, separately for primary and
# secondary, to get a single city-wide enrolment signal per phase.
# Any "Total" summary rows are already dropped by filtering School != "Total".
city_primary <- primary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::group_by(Year) %>%
dplyr::summarise(Total_offer = sum(Total_offer, na.rm = TRUE),
n_schools = dplyr::n(), .groups = "drop") %>%
dplyr::mutate(Phase = "Primary (Reception)")
city_secondary <- secondary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::group_by(Year) %>%
dplyr::summarise(Total_offer = sum(Total_offer, na.rm = TRUE),
n_schools = dplyr::n(), .groups = "drop") %>%
dplyr::mutate(Phase = "Secondary (Year 7)")
city_offers <- dplyr::bind_rows(city_primary, city_secondary)
p_city <- ggplot(city_offers,
aes(x = Year, y = Total_offer, color = Phase, group = Phase,
text = paste0(Phase, "<br>Year: ", Year,
"<br>Total offers: ",
format(Total_offer, big.mark = ","),
"<br>Schools contributing: ", n_schools))) +
geom_line(linewidth = 0.9) +
geom_point(size = 1.8) +
scale_x_continuous(breaks = seq(2010, 2026, 1)) +
scale_color_manual(values = c("Primary (Reception)" = "#1f78b4",
"Secondary (Year 7)" = "#e31a1c")) +
labs(title = "Total State-Sector Offers, Brighton & Hove",
subtitle = "Sum of offers made across all primary / secondary schools each year",
x = "Year", y = "Total offers made",
color = NULL) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "top")
ggplotly(p_city, tooltip = "text") %>%
plotly::layout(
legend = list(
orientation = "h",
x = 0.5, y = -0.18,
xanchor = "center", yanchor = "top"
)
)
```
The secondary line tracks the Year 7 cohort; the primary line tracks the Reception cohort. Together they give a rough picture of how many state-sector school places the city is actually filling each year.
## Same Cohort Aligned: Primary Offers vs Lagged Secondary Offers
The previous chart plots each year's Reception and Year 7 offers side-by-side on the calendar year. That's a useful cross-section of how many places the state sector is filling overall, but the two lines aren't tracking the *same* children. The chart below re-bases the comparison on the **cohort's Reception year**: Year 7 offers are shifted back by seven years so that they sit above the same cohort's Reception offers. A child offered a Reception place in 2014, for example, was offered a Year 7 place in 2021 — so the **2014** point on the x-axis shows the 2014 Reception total alongside the 2021 Year 7 total for the same cohort of children.
In a closed system — no migration, no moves to / from the independent sector — those two lines would be identical. Where they diverge, the gap measures net **leakage out of** or **entry into** the state sector between ages 4 and 11.
```{r}
# x-axis is the cohort's reception year. Primary offers are indexed by
# that year directly; secondary offers are indexed by (SecondaryYear - 7)
# so a family whose child started Reception in year Y appears on the
# secondary line at x = Y using the Year 7 offers made in year Y + 7.
LAG <- 7L
primary_cohort <- primary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::group_by(CohortYear = Year) %>%
dplyr::summarise(Primary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop")
secondary_cohort <- secondary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::group_by(Year) %>%
dplyr::summarise(Secondary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop") %>%
dplyr::mutate(CohortYear = Year - LAG) %>%
dplyr::select(CohortYear, Year_yr7 = Year, Secondary_offers)
cohort_long <- dplyr::bind_rows(
primary_cohort %>%
dplyr::transmute(CohortYear,
Phase = "Primary (Reception)",
Offers = Primary_offers,
AdmitYr = CohortYear),
secondary_cohort %>%
dplyr::transmute(CohortYear,
Phase = "Secondary (Year 7, +7 yrs)",
Offers = Secondary_offers,
AdmitYr = Year_yr7)
)
# Overlap band — years where both phases have data for the same cohort
overlap <- cohort_long %>%
dplyr::group_by(CohortYear) %>%
dplyr::summarise(n_phases = dplyr::n_distinct(Phase), .groups = "drop") %>%
dplyr::filter(n_phases == 2)
p_cohort <- ggplot(cohort_long,
aes(x = CohortYear, y = Offers, color = Phase, group = Phase,
text = paste0(Phase, "<br>Cohort reception year: ", CohortYear,
"<br>Admission year: ", AdmitYr,
"<br>Offers: ", format(Offers, big.mark = ","))))
if (nrow(overlap) > 0) {
p_cohort <- p_cohort +
annotate("rect",
xmin = min(overlap$CohortYear) - 0.5,
xmax = max(overlap$CohortYear) + 0.5,
ymin = -Inf, ymax = Inf,
fill = "#f0f0f0", alpha = 0.6)
}
p_cohort <- p_cohort +
geom_line(linewidth = 0.9) +
geom_point(size = 1.8) +
scale_x_continuous(
breaks = seq(min(cohort_long$CohortYear), max(cohort_long$CohortYear), 1)
) +
scale_color_manual(values = c("Primary (Reception)" = "#1f78b4",
"Secondary (Year 7, +7 yrs)" = "#e31a1c")) +
labs(title = "Same Cohort: Reception vs Year 7 Offers (Year 7 lagged by 7 years)",
subtitle = "x-axis is the cohort's Reception year; shaded band = years with both data points",
x = "Cohort Reception Year",
y = "Total offers made for this cohort",
color = NULL) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "top")
ggplotly(p_cohort, tooltip = "text") %>%
plotly::layout(
legend = list(
orientation = "h",
x = 0.5, y = -0.18,
xanchor = "center", yanchor = "top"
)
)
```
Within the shaded band both lines are available for the same cohort. The Reception line starts in 2014 (first year of primary data) and runs to 2026; the Year 7 line, because it's lagged, starts at cohort-year 2006 (from 2013 Year 7 admissions) and ends at cohort-year 2019 (from 2026 Year 7 admissions). Where both exist, a Year 7 line *below* the Reception line means net **loss** from the state-sector cohort between ages 4 and 11 (children moving to independents, moving out of the city, or being home-educated); a Year 7 line *above* the Reception line means net **entry** (families moving into the city, or moving into the state sector from independents).
### Cohort Difference: Year 7 Offers − Reception Offers (Same Cohort)
A standalone view of the gap between the two lines above. Bars show, for each cohort, Year 7 offers **minus** same-cohort Reception offers. Positive bars = the cohort grew between ages 4 and 11 (net entry); negative bars = the cohort shrank (net loss). The right-hand axis expresses the same thing as a percentage of the cohort's Reception size, so small and large cohorts can be compared fairly.
```{r}
# Inner join primary and secondary cohort totals on CohortYear so we
# only keep the years where both are available. Reuse primary_cohort /
# secondary_cohort built in the chunk above.
cohort_diff <- primary_cohort %>%
dplyr::inner_join(secondary_cohort, by = "CohortYear") %>%
dplyr::mutate(
Diff = Secondary_offers - Primary_offers,
PctDiff = 100 * Diff / Primary_offers,
Sign = ifelse(Diff >= 0, "Net entry (Y7 > Reception)",
"Net loss (Y7 < Reception)")
)
# Scale factor so PctDiff (%) can share the primary y-axis (absolute
# headcount). Pick a factor that makes the two series visually occupy
# a similar vertical range.
scale_fac <- max(abs(cohort_diff$Diff), na.rm = TRUE) /
max(abs(cohort_diff$PctDiff), na.rm = TRUE)
p_diff <- ggplot(cohort_diff,
aes(x = CohortYear,
text = paste0("Cohort reception year: ", CohortYear,
"<br>Reception (year ", CohortYear, "): ",
format(Primary_offers, big.mark = ","),
"<br>Year 7 (year ", Year_yr7, "): ",
format(Secondary_offers, big.mark = ","),
"<br>Difference: ",
ifelse(Diff >= 0, "+", ""),
format(Diff, big.mark = ","),
" (", ifelse(PctDiff >= 0, "+", ""),
formatC(PctDiff, format = "f", digits = 1),
"%)"))) +
geom_col(aes(y = Diff, fill = Sign)) +
geom_hline(yintercept = 0, colour = "grey30", linewidth = 0.4) +
geom_line(aes(y = PctDiff * scale_fac, group = 1),
colour = "#222222", linewidth = 0.6) +
geom_point(aes(y = PctDiff * scale_fac),
colour = "#222222", size = 1.8) +
scale_fill_manual(values = c("Net entry (Y7 > Reception)" = "#33a02c",
"Net loss (Y7 < Reception)" = "#e31a1c"),
name = NULL) +
scale_x_continuous(
breaks = seq(min(cohort_diff$CohortYear), max(cohort_diff$CohortYear), 1)
) +
scale_y_continuous(
name = "Year 7 offers − Reception offers (headcount)",
sec.axis = sec_axis(~ . / scale_fac,
name = "% change vs Reception cohort")
) +
labs(title = "Cohort Change Between Reception and Year 7",
subtitle = "Bars: absolute headcount difference. Line: same difference as % of Reception cohort.",
x = "Cohort Reception Year") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "right")
ggplotly(p_diff, tooltip = "text") %>%
plotly::layout(
legend = list(
orientation = "v",
x = 1.08, y = 1,
xanchor = "left", yanchor = "top"
),
margin = list(r = 180)
)
```
Reading this chart: each bar tells you how many *more* (green) or *fewer* (red) Year 7 places were offered to a given cohort than were offered to them at Reception, using the 7-year lag. The black line on the right axis normalises that same difference by the Reception cohort size, so a –100-pupil change in a large cohort doesn't look as alarming as the same –100 in a small one. Note this is a whole-city aggregate and so conflates several mechanisms (independent-sector movement, migration, home-ed, etc.) — it doesn't attribute the change to any single cause.
### Same-Cohort Comparison, by Catchment
The chart below splits the same-cohort comparison out by catchment. Each panel shows, for a single catchment, the Reception offers made to a cohort (blue) alongside the Year 7 offers made to that same cohort seven years later (red). Primary schools are assigned to a catchment by point-in-polygon on the six `optionZ` catchment polygons; **Cardinal Newman** and **Kings School** at secondary, and all **Catholic primaries**, are combined into a single **Religious schools** panel (they admit city-wide rather than on geography). Primaries that fall outside all six catchment polygons are excluded from this breakdown.
```{r}
#| column: page
#| fig-width: 14
#| fig-height: 9
#| out-width: "100%"
# Reuse the hidden `schools_with_catchment` lookup (built earlier, before
# the City-Wide section). Join primary_all and secondary_all to it to get
# a CatchmentGroup per school, then aggregate as in the whole-city
# cohort chart — but keeping CatchmentGroup as an extra grouping.
schools_primary_catch <- schools_with_catchment %>%
dplyr::filter(Phase == "Primary", !is.na(CatchmentGroup)) %>%
dplyr::select(School, CatchmentGroup)
schools_secondary_catch <- schools_with_catchment %>%
dplyr::filter(Phase == "Secondary", !is.na(CatchmentGroup)) %>%
dplyr::select(School, CatchmentGroup)
primary_cohort_catch <- primary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::inner_join(schools_primary_catch, by = "School") %>%
dplyr::group_by(CohortYear = Year, CatchmentGroup) %>%
dplyr::summarise(Primary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop")
secondary_cohort_catch <- secondary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::inner_join(schools_secondary_catch, by = "School") %>%
dplyr::group_by(Year, CatchmentGroup) %>%
dplyr::summarise(Secondary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop") %>%
dplyr::mutate(CohortYear = Year - LAG) %>%
dplyr::select(CohortYear, Year_yr7 = Year, CatchmentGroup, Secondary_offers)
cohort_long_catch <- dplyr::bind_rows(
primary_cohort_catch %>%
dplyr::transmute(CohortYear, CatchmentGroup,
Phase = "Primary (Reception)",
Offers = Primary_offers,
AdmitYr = CohortYear),
secondary_cohort_catch %>%
dplyr::transmute(CohortYear, CatchmentGroup,
Phase = "Secondary (Year 7, +7 yrs)",
Offers = Secondary_offers,
AdmitYr = Year_yr7)
)
# Fix panel order so Religious schools comes last
catch_levels <- c("BACA",
"Hove Park / Blatchington Mill",
"Longhill",
"PACA",
"Patcham",
"Varndean / Dorothy Stringer",
"Religious schools")
cohort_long_catch <- cohort_long_catch %>%
dplyr::mutate(CatchmentGroup = factor(CatchmentGroup,
levels = intersect(catch_levels,
unique(CatchmentGroup))))
p_cohort_catch <- ggplot(cohort_long_catch,
aes(x = CohortYear, y = Offers,
color = Phase, group = Phase,
text = paste0(Phase,
"<br>Catchment: ", CatchmentGroup,
"<br>Cohort reception year: ", CohortYear,
"<br>Admission year: ", AdmitYr,
"<br>Offers: ",
format(Offers, big.mark = ",")))) +
geom_line(linewidth = 0.8) +
geom_point(size = 1.4) +
scale_color_manual(values = c("Primary (Reception)" = "#1f78b4",
"Secondary (Year 7, +7 yrs)" = "#e31a1c")) +
facet_wrap(~ CatchmentGroup, ncol = 3, scales = "free_y") +
labs(title = "Same Cohort: Reception vs Year 7 Offers, by Catchment",
subtitle = "Year 7 lagged by 7 years; x-axis = cohort's Reception year",
x = "Cohort Reception Year",
y = "Total offers made for this cohort",
color = NULL) +
theme_minimal(base_size = 10) +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "top",
strip.text = element_text(face = "bold"))
ggplotly(p_cohort_catch, tooltip = "text") %>%
plotly::layout(
autosize = TRUE,
legend = list(
orientation = "v",
x = 0.68, y = 0.28,
xanchor = "left", yanchor = "top",
bgcolor = "rgba(255,255,255,0.7)",
bordercolor = "rgba(0,0,0,0.15)",
borderwidth = 1
)
)
```
Each panel's y-axis is independent (`free_y`), so the *shape* of the lines is directly comparable between catchments even where the absolute numbers differ a lot (e.g. Longhill is much smaller than Varndean / Dorothy Stringer). Within a panel, a red line *below* the blue line means the catchment lost pupils from the state sector between Reception and Year 7 for that cohort; a red line *above* means it gained them. The Religious-schools panel behaves differently from the geographic ones because primaries and secondaries in that group draw from across the city rather than a shared footprint — the comparison there is best read as "Catholic-sector Reception offers vs Kings + Cardinal Newman Y7 offers" rather than a flow within a single area.
### Who is gaining and who is losing, cohort by cohort?
The panels above show the two lines side-by-side; the chart below pulls out just the **gap** between them — Year 7 offers minus same-cohort Reception offers — so that all seven catchments can be compared directly on a single set of axes. Positive values mean that catchment's secondary schools picked up *more* offers at Year 7 than its primaries made seven years earlier (net gain). Negative values mean the opposite (net loss between ages 4 and 11). Each line is a full cohort's journey: the data point at x = 2019, for instance, tracks children offered a Reception place in 2019 and a Year 7 place in 2026.
```{r}
#| column: page
#| fig-width: 12
#| fig-height: 6
#| out-width: "100%"
cohort_gap_catch <- cohort_long_catch %>%
dplyr::select(CohortYear, CatchmentGroup, Phase, Offers) %>%
tidyr::pivot_wider(names_from = Phase, values_from = Offers) %>%
dplyr::rename(Primary = `Primary (Reception)`,
Secondary = `Secondary (Year 7, +7 yrs)`) %>%
dplyr::filter(!is.na(Primary), !is.na(Secondary)) %>%
dplyr::mutate(Gap = Secondary - Primary,
GapPct = 100 * Gap / Primary,
AdmitYr = CohortYear + LAG)
catch_palette <- c(
"BACA" = "#e41a1c",
"Hove Park / Blatchington Mill" = "#377eb8",
"Longhill" = "#4daf4a",
"PACA" = "#984ea3",
"Patcham" = "#ff7f00",
"Varndean / Dorothy Stringer" = "#a65628",
"Religious schools" = "#111111"
)
p_gap <- ggplot(cohort_gap_catch,
aes(x = CohortYear, y = Gap,
color = CatchmentGroup, group = CatchmentGroup,
text = paste0(
CatchmentGroup,
"<br>Cohort Reception year: ", CohortYear,
" (Y7 ", AdmitYr, ")",
"<br>Reception offers: ", format(Primary, big.mark = ","),
"<br>Y7 offers: ", format(Secondary, big.mark = ","),
"<br>Gap: ", ifelse(Gap > 0, "+", ""), format(Gap, big.mark = ","),
" (", sprintf("%+.1f", GapPct), "%)"))) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey40") +
geom_line(linewidth = 1) +
geom_point(size = 2) +
scale_x_continuous(breaks = seq(min(cohort_gap_catch$CohortYear),
max(cohort_gap_catch$CohortYear), 1)) +
scale_color_manual(values = catch_palette) +
labs(title = "Net gain / loss between Reception and Year 7, by catchment",
subtitle = "Y7 offers minus same-cohort Reception offers (7-year lag). Positive = gain; Negative = loss.",
x = "Cohort Reception year",
y = "Year 7 offers − Reception offers",
color = NULL) +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "right")
ggplotly(p_gap, tooltip = "text") %>%
plotly::layout(
autosize = TRUE,
margin = list(t = 80, r = 40, b = 80, l = 60),
legend = list(
orientation = "v",
x = 1.02, y = 1,
xanchor = "left", yanchor = "top",
bgcolor = "rgba(255,255,255,0.7)",
bordercolor = "rgba(0,0,0,0.15)",
borderwidth = 1
)
)
```
Three things jump out of this chart:
1. **Religious schools are the only catchment in persistent net *gain*.** Across every cohort we have data for, Cardinal Newman and Kings between them hand out substantially more Year 7 offers than the Catholic primaries that feed them make at Reception — and the gap has been widening. Because both CN and Kings admit city-wide rather than on a geographic catchment, every extra place they fill is a pupil who switched *into* faith secondary education somewhere between ages 4 and 11, drawn from the geographic catchments (or from outside the LEA / independent sector). The net gain for the religious bloc is the structural counterpart of the losses showing up in the geographic catchments — most of those "lost" pupils haven't left the state sector at all, they've just moved across it to CN or Kings.
2. **Longhill is the biggest structural loser, and the trend is accelerating.** Its same-cohort gap has deepened year on year across the six cohorts plotted, consistent with the rising empirical leakage rate discussed in the council-forecast section. BACA and PACA sit in the same territory, though less severely and less monotonically.
3. **Hove Park / Blatchington Mill and Varndean / Dorothy Stringer are close to balance** — small, noisy losses that don't obviously trend. These are the catchments whose secondaries are already operating at or near their PANs, so there isn't much head-room for *net* growth even if some pupils are moving in.
### How much of the "loss" leaves the state sector? An FOI crosscheck
A [2024 FOI request](https://www.whatdotheyknow.com/request/schools_admissions_breakdowns_fo) published on WhatDoTheyKnow (summarised in [this Google Sheet](https://docs.google.com/spreadsheets/d/1F_NDqefjBBeG8TMUXkUx3bF5WG2BuDGmpNAwm-Ct7Rc/edit?usp=sharing) from a B&H parent group) lets us put a size on the two mechanisms separately for the 2024 Year 7 intake. The FOI gives, per catchment, the number of children from that catchment who were *offered* a state secondary place on national offer day and the number who had actually *taken up* a state secondary place by the autumn census.
For the 2024 cohort, the city-level figures are:
- **2,468** Y7 offers made to children resident in B&H catchments (plus a small out-of-LEA group).
- **2,306** children from those same catchments attending a B&H or neighbouring-LEA state school in the autumn.
- **−162** pupils — the difference BHCC describes in its FOI response as "the vast majority" going to independent schools.
That 162-pupil autumn fall-off is the *genuinely out-of-state* leakage for 2024, which is roughly **6.5 %** of the offers made. It is *not* the same quantity as the Reception-to-Y7 gaps plotted above: those gaps compound seven years of movement (independent-sector switches, families moving in or out of Brighton, home-ed, SEND placements, as well as churn between state schools), whereas the FOI figure is a single-year snapshot of the post-offer step only. But the ordering is informative. In 2024, the biggest autumn fall-offs came from **Hove Park / Blatchington Mill (−69 pupils, ≈ −9 %)** and **Varndean / Dorothy Stringer (−38, ≈ −5 %)** — the two catchments that are roughly balanced on the Reception-to-Y7 chart above. That is consistent with the interpretation that most of what shows up as a seven-year same-cohort *loss* in the geographic catchments is *not* leakage to the private sector but **movement across the state sector into Kings / Cardinal Newman**. The FOI confirms CN and Kings are net receivers in every catchment — in 2024 alone they absorbed **24 PACA pupils, 255 Hove Park / BMS pupils, 71 Varndean / DS pupils, 64 Longhill pupils, 22 BACA pupils, 27 Patcham pupils and 53 Out-of-LEA pupils at offer stage** (treating CN and Kings as "in-catchment" only for the Hove Park / BMS area as the FOI sheet does). The out-of-state component is real but relatively small; the far larger flow is within-state reallocation towards the two faith schools.
## Secondary Offers by Catchment (and Religious Schools)
The six geographic secondary catchments are represented by eight schools. Two of Brighton & Hove's secondary schools — **Kings School** and **Cardinal Newman Catholic School** — are faith schools that admit across the whole city and so sit outside the geographic catchment framework; they are combined here as **Religious schools**.
```{r}
# Map each secondary school to its catchment. Two of the six geographic
# catchments (Hove Park / Blatchington Mill, Varndean / Dorothy Stringer)
# contain two schools each; those pairs are summed. Kings + Cardinal Newman
# are aggregated as "Religious schools" (both are faith schools admitting
# city-wide, outside the geographic catchment framework).
catchment_lookup <- tibble::tribble(
~School, ~CatchmentGroup,
"Brighton Aldridge Community Academy", "BACA",
"Blatchington Mill", "Hove Park / Blatchington Mill",
"Hove Park", "Hove Park / Blatchington Mill",
"Longhill High", "Longhill",
"Patcham High", "Patcham",
"Portslade Aldridge Community Academy", "PACA",
"Varndean", "Varndean / Dorothy Stringer",
"Dorothy Stringer", "Varndean / Dorothy Stringer",
"Kings School", "Religious schools",
"Cardinal Newman", "Religious schools"
)
catchment_palette <- c(
"BACA" = "#e41a1c",
"Hove Park / Blatchington Mill" = "#377eb8",
"Longhill" = "#4daf4a",
"PACA" = "#984ea3",
"Patcham" = "#ff7f00",
"Varndean / Dorothy Stringer" = "#a65628",
"Religious schools" = "#666666"
)
catchment_offers <- secondary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::inner_join(catchment_lookup, by = "School") %>%
dplyr::group_by(Year, CatchmentGroup) %>%
dplyr::summarise(
Total_offer = sum(Total_offer, na.rm = TRUE),
Schools = paste(sort(unique(School)), collapse = " + "),
.groups = "drop"
)
p_catch <- ggplot(catchment_offers,
aes(x = Year, y = Total_offer, color = CatchmentGroup,
group = CatchmentGroup,
text = paste0(CatchmentGroup, "<br>Year: ", Year,
"<br>Offers: ",
format(Total_offer, big.mark = ","),
"<br>Schools: ", Schools))) +
geom_line(linewidth = 0.8) +
geom_point(size = 1.6) +
scale_x_continuous(breaks = seq(2010, 2026, 1)) +
scale_color_manual(values = catchment_palette) +
labs(title = "Secondary Offers by Catchment (and Religious Schools)",
subtitle = "Two-school catchments (Hove Park / Blatchington Mill, Varndean / Dorothy Stringer) summed; Kings + Cardinal Newman combined",
x = "Year", y = "Total offers made",
color = NULL) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom")
ggplotly(p_catch, tooltip = "text") %>%
plotly::layout(
legend = list(
orientation = "h",
x = 0.5, y = -0.2,
xanchor = "center", yanchor = "top"
),
margin = list(t = 90, b = 120)
)
```
Because two-school catchments are aggregated, all seven lines are on a comparable "catchment demand" footing. The Religious-schools line captures the total offer volume of the two faith schools together, which can rise or fall independently of the geographic catchments.
## Applications per Slot by Catchment
The per-preference-slot standardisation applied to the secondary applications chart above can also be aggregated by catchment. Each catchment's total applications (sum across its one or two schools) is divided by the number of preference slots available in that year (3 up to 2025, 4 from 2026), so any 2026 jump driven purely by adding a 4th slot is removed and catchments can be compared on a like-for-like demand basis across the whole period.
```{r}
catchment_apps_std <- secondary_all %>%
dplyr::filter(School != "Total", !is.na(Total)) %>%
dplyr::inner_join(catchment_lookup, by = "School") %>%
dplyr::group_by(Year, CatchmentGroup) %>%
dplyr::summarise(
Total = sum(Total, na.rm = TRUE),
Schools = paste(sort(unique(School)), collapse = " + "),
.groups = "drop"
) %>%
dplyr::mutate(
slots = ifelse(Year >= 2026, 4L, 3L),
PerSlot = Total / slots
)
p_catch_std <- ggplot(catchment_apps_std,
aes(x = Year, y = PerSlot, color = CatchmentGroup,
group = CatchmentGroup,
text = paste0(CatchmentGroup, "<br>Year: ", Year,
"<br>Slots available: ", slots,
"<br>Total applications: ",
format(Total, big.mark = ","),
"<br>Applications / slot: ",
formatC(PerSlot, format = "f", digits = 1),
"<br>Schools: ", Schools))) +
geom_line(linewidth = 0.8) +
geom_point(size = 1.6) +
scale_x_continuous(breaks = seq(2010, 2026, 1)) +
scale_color_manual(values = catchment_palette) +
labs(title = "Secondary Applications per Preference Slot, by Catchment",
subtitle = "Catchment-total applications ÷ preference slots available (3 up to 2025, 4 from 2026)",
x = "Year",
y = "Applications ÷ slots available",
color = NULL) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom")
ggplotly(p_catch_std, tooltip = "text") %>%
plotly::layout(
legend = list(
orientation = "h",
x = 0.5, y = -0.2,
xanchor = "center", yanchor = "top"
),
margin = list(t = 90, b = 120)
)
```
A line that is broadly flat between 2025 and 2026 on this chart means the catchment's 2026 rise in the raw by-catchment chart above is essentially the extra preference slot — not a real increase in families choosing that catchment. A line that steps clearly *up* from 2025 to 2026 is picking up genuine additional demand per slot; a line that steps *down* is losing demand even before the mechanical slot inflation is applied.
# Council Forecast (BHCC) vs Actual Offers
The council has now published **two successive** Year-7 pupil forecasts, built with the same method but from different census snapshots:
- [Appendix 6 — Oct 2024 forecast](https://democracy.brighton-hove.gov.uk/documents/s211108/Appendix%206%20-%20Secondary%20School%20Pupil%20Forecast.pdf) (secondary entry **2025–2031**).
- [Appendix 7 — Oct 2025 forecast](https://democracy.brighton-hove.gov.uk/documents/s212430/School%20Admission%20Arrangements%202027-28%20APX.%20n%207.pdf) (secondary entry **2026–2032**).
The Oct 2025 forecast is the council's current view and is used as the primary reference below; the Oct 2024 forecast is retained because it supplies the only council-published projection for **2025** (now an actual offer year) and because comparing the two reveals how the council has revised its outlook year-on-year.
The council's method, summarised from the two PDFs:
1. Start with the raw number of pupils living in each catchment in the relevant year group of the school census (Oct 2024 or Oct 2025 respectively).
2. Reduce by a catchment-specific leakage percentage for pupils expected to move out of the state-maintained sector (independents, home-ed, out-of-city). Leakage rates in the Oct 2025 forecast: PACA **2.46%**, Hove Park / Blatchington Mill **5.13%**, Varndean / Stringer **4.58%**, BACA **6.52%**, Patcham **3.03%**, Longhill **23.89%** — the Oct 2024 values were similar but slightly lower (e.g. Longhill was 22.61%).
3. Further reduce by the historical share of the catchment going to **Cardinal Newman** or **Kings School** — the two city-wide faith secondaries that sit outside the geographic catchment framework.
4. The resulting "adjusted for CN & Kings" figure is the forecast demand for the catchment's own schools. Compared with the catchment PAN this gives a surplus (`+`) or shortfall (`-`) of places.
## Forecast Demand by Catchment (adjusted for CN & Kings)
```{r}
# Oct 2025 forecast (the council's current view). Columns cover Y7 entry in
# 2026-2032 — i.e. the children who were in Year 6 down to Reception at the
# time of the October 2025 school census.
bhcc_forecast <- tibble::tribble(
~CatchmentGroup, ~PAN, ~Y2026, ~Y2027, ~Y2028, ~Y2029, ~Y2030, ~Y2031, ~Y2032,
"PACA", 220L, 218L, 178L, 194L, 179L, 212L, 147L, 173L,
"Hove Park / Blatchington Mill", 510L, 424L, 438L, 390L, 410L, 351L, 373L, 311L,
"Varndean / Dorothy Stringer", 630L, 601L, 588L, 563L, 530L, 543L, 527L, 496L,
"Longhill", 210L, 162L, 148L, 160L, 146L, 129L, 148L, 100L,
"BACA", 180L, 128L, 135L, 138L, 112L, 97L, 121L, 103L,
"Patcham", 225L, 194L, 203L, 200L, 185L, 168L, 172L, 162L
)
# Oct 2024 forecast kept as reference. Columns cover Y7 entry in 2025-2031
# (from the Oct 2024 school census) — useful for the 2025 actual vs forecast
# comparison, and for contrasting the two successive forecasts.
bhcc_forecast_oct24 <- tibble::tribble(
~CatchmentGroup, ~PAN, ~Y2025, ~Y2026, ~Y2027, ~Y2028, ~Y2029, ~Y2030, ~Y2031,
"PACA", 220L, 206L, 221L, 185L, 191L, 182L, 214L, 150L,
"Hove Park / Blatchington Mill", 510L, 512L, 434L, 443L, 410L, 413L, 351L, 365L,
"Varndean / Dorothy Stringer", 630L, 581L, 624L, 592L, 588L, 560L, 557L, 539L,
"Longhill", 210L, 168L, 175L, 166L, 176L, 159L, 151L, 154L,
"BACA", 180L, 134L, 129L, 138L, 143L, 112L, 95L, 125L,
"Patcham", 225L, 198L, 205L, 212L, 201L, 194L, 163L, 179L
)
# Surplus / shortfall from the Oct 2025 forecast (published directly in the PDF).
bhcc_surplus <- tibble::tribble(
~CatchmentGroup, ~Y2026, ~Y2027, ~Y2028, ~Y2029, ~Y2030, ~Y2031, ~Y2032,
"PACA", 2L, 42L, 26L, 41L, 8L, 73L, 47L,
"Hove Park / Blatchington Mill", 86L, 72L, 120L, 100L, 159L, 137L, 199L,
"Varndean / Dorothy Stringer", 29L, 42L, 67L, 100L, 87L, 103L, 134L,
"Longhill", 48L, 62L, 50L, 64L, 81L, 62L, 110L,
"BACA", 52L, 45L, 42L, 68L, 83L, 59L, 77L,
"Patcham", 31L, 22L, 25L, 40L, 57L, 53L, 63L
)
# Totals row from the Oct 2025 forecast.
bhcc_forecast_with_total <- bhcc_forecast %>%
dplyr::bind_rows(
tibble::tibble(
CatchmentGroup = "Total (catchment schools)",
PAN = 1975L,
Y2026 = 1726L, Y2027 = 1690L, Y2028 = 1644L, Y2029 = 1562L,
Y2030 = 1500L, Y2031 = 1488L, Y2032 = 1344L
),
tibble::tibble(
CatchmentGroup = "Total (all schools, incl. Kings + CN)",
PAN = 2500L,
Y2026 = 2251L, Y2027 = 2215L, Y2028 = 2169L, Y2029 = 2087L,
Y2030 = 2025L, Y2031 = 2013L, Y2032 = 1869L
)
)
knitr::kable(bhcc_forecast_with_total,
col.names = c("Catchment", "PAN",
"2026", "2027", "2028", "2029", "2030", "2031", "2032"),
align = "lrrrrrrrr",
caption = "Forecast Y7 demand (adjusted for Cardinal Newman & Kings), BHCC Oct-25 census projection (Appendix 7)")
```
## Forecast Surplus / Shortfall of Places
```{r}
knitr::kable(bhcc_surplus,
col.names = c("Catchment",
"2026", "2027", "2028", "2029", "2030", "2031", "2032"),
align = "lrrrrrrr",
caption = "Surplus (+) or shortfall (–) of places: PAN minus Oct-25 forecast adjusted demand")
```
Positive values = spare capacity; negative values = demand exceeds PAN. In the Oct 2025 forecast the council now sees a rising surplus at city level — from **10 %** of all secondary places in 2026 to **19 %** in 2031 and **25 %** in 2032. The previous Oct 2024 forecast had demand *just* exceeding PAN at Hove Park / Blatchington Mill in 2025 (–2); the new forecast shows a small surplus (+86) at the same catchment a year on. Every catchment-year cell in the Oct 2025 forecast has a positive (surplus) balance — there is no longer a single catchment-year where forecast demand exceeds PAN.
## How Did the 2025 and 2026 Forecasts Compare With What Actually Happened?
2025 and 2026 are the two secondary-entry years for which we now have actual offers data. For 2025, only the Oct-24 forecast was ever published; 2026 is the only year for which **both** forecasts can be checked against reality — so that's the most informative row of all.
Two caveats on comparability:
- The council's "adjusted for CN & Kings" figure is a projected demand from children *living in* the catchment who are expected to seek a state non-faith place. Our "actual offers" is the number of Y7 places *issued at* the catchment's schools — which will include some cross-catchment pupils and excludes in-catchment children who were offered a place elsewhere. Offers are also hard-capped at PAN.
- The Kings + Cardinal Newman totals aren't broken out as a catchment row. Instead each geographic catchment's projection is reduced each year by a *per-year* estimate of how many of its resident pupils will go to Cardinal Newman and Kings. In the Oct-25 forecast this is **355 / yr** to CN and **170 / yr** to Kings — **525 / yr** combined, vs PAN of **540** (CN 360 + Kings 180). The PDFs therefore implicitly assume the two faith schools run essentially full, with any residual places filled from outside the six geographic catchments (out-of-city faith applicants, etc.). The Oct-24 forecast had slightly lower implied CN + Kings demand (330 + 167 = **497 / yr**).
```{r}
# Our actual offers per catchment per year, reusing the `catchment_offers`
# data built for the by-catchment offers chart earlier.
actual_offers_2526 <- catchment_offers %>%
dplyr::filter(Year %in% c(2025L, 2026L)) %>%
dplyr::select(Year, CatchmentGroup, Total_offer) %>%
tidyr::pivot_wider(names_from = Year, values_from = Total_offer,
names_prefix = "Actual_")
# 2025: only the Oct-24 forecast has a 2025 figure.
compare_2025 <- bhcc_forecast_oct24 %>%
dplyr::select(CatchmentGroup, PAN, Forecast_oct24_2025 = Y2025) %>%
dplyr::left_join(actual_offers_2526 %>% dplyr::select(CatchmentGroup, Actual_2025),
by = "CatchmentGroup") %>%
dplyr::mutate(Diff_2025 = Actual_2025 - Forecast_oct24_2025) %>%
dplyr::select(CatchmentGroup, PAN,
Forecast_oct24_2025, Actual_2025, Diff_2025)
knitr::kable(compare_2025,
col.names = c("Catchment", "PAN",
"Oct-24 forecast 2025", "Actual 2025", "Δ"),
align = "lrrrr",
format.args = list(big.mark = ","),
caption = "2025: council Oct-24 forecast vs actual Y7 offers")
```
```{r}
# 2026: both forecasts are available, plus actual offers.
compare_2026 <- bhcc_forecast_oct24 %>%
dplyr::select(CatchmentGroup, PAN, Oct24 = Y2026) %>%
dplyr::left_join(
bhcc_forecast %>% dplyr::select(CatchmentGroup, Oct25 = Y2026),
by = "CatchmentGroup"
) %>%
dplyr::left_join(actual_offers_2526 %>% dplyr::select(CatchmentGroup, Actual_2026),
by = "CatchmentGroup") %>%
dplyr::mutate(
Diff_A_minus_Oct24 = Actual_2026 - Oct24,
Diff_A_minus_Oct25 = Actual_2026 - Oct25,
Diff_Oct25_minus_Oct24 = Oct25 - Oct24
) %>%
dplyr::select(CatchmentGroup, PAN,
Oct24, Oct25, Actual_2026,
Diff_A_minus_Oct24, Diff_A_minus_Oct25,
Diff_Oct25_minus_Oct24)
knitr::kable(compare_2026,
col.names = c("Catchment", "PAN",
"Oct-24 forecast", "Oct-25 forecast", "Actual 2026",
"Δ (Actual − Oct-24)", "Δ (Actual − Oct-25)",
"Δ (Oct-25 − Oct-24)"),
align = "lrrrrrrr",
format.args = list(big.mark = ","),
caption = "2026: both council forecasts vs actual Y7 offers — and how the council revised its view between Oct-24 and Oct-25")
```
```{r}
# Religious schools: actuals vs PDF-implied demand (both forecasts).
relig_actual <- secondary_all %>%
dplyr::filter(School %in% c("Kings School", "Cardinal Newman"),
Year %in% c(2025L, 2026L)) %>%
dplyr::select(Year, School, Total_offer) %>%
tidyr::pivot_wider(names_from = School, values_from = Total_offer) %>%
dplyr::mutate(`CN + Kings total` = `Cardinal Newman` + `Kings School`,
`Oct-24 implied (per yr)` = 330L + 167L,
`Oct-25 implied (per yr)` = 355L + 170L)
knitr::kable(relig_actual,
align = "lrrrrr",
format.args = list(big.mark = ","),
caption = "Religious schools: actual Y7 offers vs each forecast's implied per-year demand (CN + Kings, summed across catchments)")
```
**Headline read for 2025 and 2026:**
- **Varndean / Dorothy Stringer** — both forecasts under-estimated demand. In 2025 the Oct-24 forecast was 581 vs actual 630 (PAN cap, Δ +49). In 2026 the Oct-24 forecast was 624 and the *revised* Oct-25 forecast is **601** — actual still hit PAN of **630** (Δ +29 against Oct-25). So the council revised demand *down* by 23 pupils between the two forecasts, but actual offers went the other way. These two schools stay full regardless of what the in-catchment demand model says — out-of-catchment demand tops them up.
- **Longhill** — the most dramatic forecast-vs-actual gap. Oct-24 forecast **175** for 2026; Oct-25 revised to **162**; actual **81** (Δ –81 against Oct-25). Offers are less than **half** what the council now models, and well under PAN 210. The Oct-25 revision moves in the right direction but still over-estimates actual demand by a factor of two. Backing out an implied leakage from 2026 actuals gives roughly 67 %, vs the 23.89 % leakage baked into the Oct-25 forecast.
- **BACA** — 2025 actual (**89**) fell well below the Oct-24 forecast of **134**, but 2026 flipped: actual **146** against forecasts of **129** (Oct-24) / **128** (Oct-25), i.e. +17–18 above both. The Oct-25 revision kept BACA essentially unchanged from Oct-24 — the council didn't update its view here, and the 2026 bounce-back in offers isn't picked up by either forecast.
- **PACA** — consistently below forecast and below PAN. 2025 actual **190** (Oct-24 forecast 206, PAN 220). 2026 actual **183** against Oct-24 forecast **221** and Oct-25 forecast **218** — Δ –35 against the revised forecast. The Oct-25 revision barely changes the 2026 view (–3 vs Oct-24) but real-world demand was a further 35 below that.
- **Hove Park / Blatchington Mill** — 2025 tracked the Oct-24 forecast closely (offers 501 vs forecast 512, both near PAN 510, Δ –11). 2026 is the striking case: the Oct-24 forecast projected a sharp drop to 434; Oct-25 revised **down further** to **424**; actual offers came in at **466**, i.e. **+42** above the Oct-25 forecast. The council is increasingly bearish on Hove Park / Blatch demand, but out-of-catchment pull-in is propping up offers.
- **Patcham** — Oct-24 was the most accurate forecast on the table (198 / 205 against 202 / 204 actual, Δ +4 / –1). Oct-25 revised 2026 demand **down by 11** to **194**, but actual 2026 offers were **204** — so the revision moved *away* from reality by about 10 pupils.
- **Religious schools (Kings + CN)** — both schools are at PAN in both years (CN 360 + Kings 180 = **540**). The Oct-24 forecast's implied demand of 497 / yr rose to **525 / yr** in the Oct-25 forecast, closing the gap with PAN. The council has (rightly) calibrated the faith-school draw upward, but both forecasts still under-state what actually happens there — the 15-pupil residual is the outside-catchment faith intake (out-of-city applicants, siblings, late admits) that no catchment-based model can see.
**The Oct-25 revision, in one paragraph.** The new forecast is **almost uniformly lower** than its predecessor for every catchment-year overlap. For 2026 specifically, the revisions are: Hove Park / Blatch −10, Varndean / Stringer −23, Longhill −13, Patcham −11, BACA −1, PACA −3. Compared with actual 2026 offers, the Oct-25 revision is closer to reality at **PACA** and **Longhill** (where the Oct-24 forecast was too high), and *further from reality* at **Hove Park / Blatch, Varndean / Stringer, BACA and Patcham** (where actual offers ran above both forecasts). The pattern is consistent with the council's method being a **residential-demand** model: when overall city demand falls, every forecast drops, but actual **offers** at individual schools are governed by where families apply — and Hove Park / Blatch, Varndean / Stringer and Patcham continue to attract more pupils than their own catchment produces.
**Bottom line.** For the supply-constrained catchments (Varndean / Stringer, the religious schools) the council's forecasts are essentially a lower bound on offers — both forecasts correctly predict demand near or above PAN. For the under-filled catchments (Longhill, BACA, PACA), the Oct-25 forecast is closer to reality than Oct-24 but still optimistic: actual under-fill at **Longhill** in particular is substantially worse than even the revised forecast implies.
At the city level the council's Oct-25 forecast projects **2,251** (2026) aggregate demand, revised down from 2,284 in the Oct-24 forecast. The city-wide aggregate check against actual 2026 Y7 offers is worked through in the cohort-projection section below — for now, the main takeaway is that the forecast revisions and remaining disagreements are almost entirely about *distribution* across catchments rather than the city-level total.
# Our Own Projection: Cohort-Aging From Primary Allocations
### How our projection method works
We don't have access to the Oct 2024 school census, but we do have something closely related: **primary offers by catchment** from this document, going back to 2014. Each primary cohort admitted to Reception in year *Y* will be applying for Y7 in year *Y + 7*, so the primary allocation data carries the same cohort signal the council uses — just observed earlier in the pipeline, and measured by "where each child got a Reception place" rather than "where each child lives".
The method is a single-step **empirical flow ratio**:
1. **Assign every primary and secondary school in our dataset to a catchment** by point-in-polygon on the council's `optionZ_Mar25.geojson` boundary file (used earlier for the by-catchment charts). Faith primaries (any school whose name contains "Catholic Primary") and the two faith secondaries (Kings, Cardinal Newman) are overridden to a seventh "Religious schools" group, because these schools admit city-wide rather than on geography.
2. **For each catchment, compute the ratio Secondary Y7 offers(Y + 7) ÷ Reception offers(Y)** for every pair of years where both values are available. Our data overlaps for six cohorts: Reception 2014 → Y7 2021, through Reception 2019 → Y7 2026. The result is a six-value sample of the catchment's "Reception-to-Y7 retention" per cohort, and we take the **mean** as the central projection value (the SD in the retention-ratio table above gives the spread).
3. **Apply that single retention ratio** to each catchment's primary offers in 2020–2024 to project Y7 offers in 2027–2031. The projection year equals primary-offer year plus seven.
What the retention ratio absorbs — all in one number, without any attempt to decompose them:
- **State-to-private and similar sector leakage** between ages 4 and 11 (children who start Reception at a state primary and later move to independents, home-ed, or out of the city).
- **Cross-sector Y7 flow** towards Cardinal Newman and Kings (whose intake at Y7 is larger than the Catholic-primary intake at Reception seven years earlier — that's why the Religious schools ratio comes out above 2).
- **Cross-catchment Y7 flow** between geographic catchments, where a child lives in one catchment and lists a different one as their first preference.
- **PAN capping** at Y7. Where a catchment runs at PAN regardless of underlying demand (historically Varndean / Dorothy Stringer and the religious schools), the fitted ratio is effectively PAN ÷ primary-offer volume — future projections will therefore predict more-of-the-same-PAN-filling unless the primary feeder shrinks enough that primary × ratio drops below PAN.
- **Geographic mismatch between the primary and secondary "catchment"**. Primary admissions aren't organised by secondary catchment — families apply to individual primary schools, each of which has its own much smaller admission area. Our per-catchment primary-offers number is therefore the sum of offers at all primary schools whose site happens to fall inside a given secondary catchment polygon, which is a reasonable but imperfect proxy for "children resident in the catchment at age 4".
Because the ratio is held constant when projecting forward, the method implicitly assumes the mix of those mechanisms stays the same as it was over the 2014–2019 Reception cohorts (roughly: pre-pandemic with two pandemic-affected cohorts mixed in). If any of them shift materially — say, more families leaving for independents, or Kings / CN attracting a larger share of non-Catholic primary pupils — the projection will drift from reality. The SD column in the retention-ratio table is the simplest read on how stable each catchment's ratio has been over the six cohorts: low for Hove Park / Blatchington Mill (0.04), Patcham (0.02) and Varndean / Stringer (0.03); noticeably higher for BACA (0.23) and Longhill (0.13).
### Worked example: applying the method to the Patcham catchment
To make the method concrete, here is the whole calculation for a single catchment. We pick **Patcham** because it is the simplest case — a single primary group feeding a single secondary school (Patcham High) — and because its cohort-to-cohort retention ratio has the lowest standard deviation of any catchment (0.02), so the "mean" figure is genuinely representative. The figures below are computed live from the same underlying dataset used elsewhere in this document.
```{r}
#| echo: false
# Worked example: Patcham catchment only. Self-contained so the chunk
# does not depend on retention / projection objects built further down.
worked_catch <- "Patcham"
LAG_WE <- 7L
we_primary <- primary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::inner_join(
schools_with_catchment %>%
dplyr::filter(Phase == "Primary", !is.na(CatchmentGroup),
CatchmentGroup == worked_catch) %>%
dplyr::select(School, CatchmentGroup),
by = "School"
) %>%
dplyr::group_by(Year) %>%
dplyr::summarise(Primary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop")
we_secondary <- secondary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::inner_join(
schools_with_catchment %>%
dplyr::filter(Phase == "Secondary", !is.na(CatchmentGroup),
CatchmentGroup == worked_catch) %>%
dplyr::select(School, CatchmentGroup),
by = "School"
) %>%
dplyr::group_by(Year) %>%
dplyr::summarise(Secondary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop")
we_pairs <- we_primary %>%
dplyr::mutate(Secondary_year = Year + LAG_WE) %>%
dplyr::inner_join(we_secondary,
by = c("Secondary_year" = "Year")) %>%
dplyr::rename(Primary_year = Year) %>%
dplyr::mutate(Ratio = Secondary_offers / Primary_offers) %>%
dplyr::arrange(Primary_year)
we_mean <- mean(we_pairs$Ratio, na.rm = TRUE)
we_sd <- sd(we_pairs$Ratio, na.rm = TRUE)
knitr::kable(
we_pairs %>%
dplyr::transmute(
`Reception year (Y)` = Primary_year,
`Reception offers (P_Y)` = Primary_offers,
`Y7 year (Y+7)` = Secondary_year,
`Y7 offers (S_{Y+7})` = Secondary_offers,
`Ratio S_{Y+7} / P_Y` = round(Ratio, 3)
),
align = "rrrrr",
format.args = list(big.mark = ","),
caption = sprintf(
"Step 1 — Patcham: six cohort pairs observed in the data. Mean retention ratio = %.3f (SD %.3f).",
we_mean, we_sd)
)
```
**What this table shows.** Each row is one cohort. For the Reception 2014 cohort, Patcham's primaries made `P_2014` offers; seven years later those same children were in Y7 and Patcham High made `S_2021` offers. The ratio `S_2021 / P_2014` is how many Year 7 Patcham-catchment offers that catchment ended up with for every Reception offer it made seven years earlier. Doing this for all six overlapping cohorts and taking the mean gives Patcham's fitted retention ratio, reported at the bottom of the caption.
```{r}
#| echo: false
# Apply the Patcham ratio to its 2020..2026 Reception offers to
# project 2027..2033 Y7. Uses the same we_primary object built above.
we_project <- we_primary %>%
dplyr::filter(Year >= 2020L, Year <= 2026L) %>%
dplyr::arrange(Year) %>%
dplyr::transmute(
`Reception year (Y)` = Year,
`Reception offers (P_Y)` = Primary_offers,
`Retention ratio (r)` = sprintf("%.3f", we_mean),
`Projected Y7 offers = P_Y x r` = round(Primary_offers * we_mean),
`Y7 year (Y+7)` = Year + LAG_WE
)
knitr::kable(
we_project,
align = "rrrrr",
format.args = list(big.mark = ","),
caption = sprintf(
"Step 2 — Patcham: apply the fitted ratio (r = %.3f) to Reception cohorts 2020-2026 to project Y7 2027-2033.",
we_mean)
)
```
**Reading the projection.** Each row takes a real Reception-year offer count that has already been observed in Patcham's primary admissions, multiplies it by the single fitted ratio `r`, and lands the resulting number in the corresponding Y7 year seven years later. So, for example, the Reception 2023 cohort (`P_2023`) is already in Y3 at real Patcham-area primaries today; our projection simply assumes it will reach Y7 in 2030 at roughly the same rate its predecessor cohorts did, and reports `P_2023 × r` rounded to the nearest pupil. No demographic re-estimation, no census layer, no leakage percentage — everything is contained in the single empirical `r`.
**Why this is informative and where it can go wrong.** For Patcham, the six historic cohort pairs gave ratios clustered tightly around 0.83–0.87 (SD 0.02), meaning Patcham High has reliably ended up recruiting roughly 85 % of the children that the catchment's primaries took on seven years earlier — the missing \~15 % is the combined net effect of private-sector leakage, the Catholic-schools flow, a little cross-catchment movement, and families leaving the city. If *any* of those components shifts in a future cohort — for example if the combined FSM + Open Allocation stack discussed elsewhere in this document starts pulling more Patcham children into Varndean / Stringer than it has historically — the real 2030 figure will come in below the projected one. The projection is therefore only as good as the assumption that the 2014–2019 cohorts' mixture of drivers is a reasonable stand-in for the 2020–2026 cohorts' future. For catchments with low SD (Patcham, HP / BMS, V / DS) that assumption is well-supported by the data; for catchments with higher SD (BACA, Longhill) the point projection is less reliable and the spread of possible outcomes is wider.
The rest of this section generalises the same two-step calculation to every catchment at once.
### How the council's method appears to work
The Appendix 6 PDF is explicit about its main inputs. For each catchment the council starts with the raw number of children in each primary year group of the **Oct 2024 school census** who are resident in that catchment (column header "school Census by year in Oct 24"). These seven columns become the seven years of the Y7 forecast (R → 2031, Year 6 → 2025).
Three adjustments are then applied to each column:
1. A **catchment-specific leakage percentage** is deducted — ranging from 2.01 % (PACA) to 22.61 % (Longhill) — representing children expected to leave the state, non-faith sector between the census date and Y7 entry. This is evidently calibrated from historical patterns of how many children in each catchment end up in independents, out-of-city, or home-educated. The same percentage is applied to every year group in that catchment.
2. A **fixed annual number going to Cardinal Newman and Kings** is subtracted — the "Estimated number going to CN / Kings" values in the right-hand columns of the PDF. These appear once per catchment, not per year, so they're best understood as a long-run average.
3. The result is the council's **adjusted demand** for each catchment's non-faith secondary schools. Compared with the catchment PAN, it becomes the published surplus/shortfall row.
In method-speak, the council's approach is an **additive decomposition**: raw cohort → minus % leakage → minus CN/Kings average → adjusted demand. Ours is a **multiplicative empirical ratio** that wraps every one of those steps (plus a few the council doesn't model explicitly — cross-catchment flow and PAN capping) into a single per-catchment number fitted from historical outcomes. The two approaches are starting from related but different data (pupil residence vs primary-offer location), applying different structural assumptions (explicit decomposition vs absorbed ratio), and projecting different things (residential demand vs expected offers at catchment schools).
```{r}
# --- Build primary and secondary offers by (Year, CatchmentGroup) ---
primary_by_catch <- primary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::inner_join(
schools_with_catchment %>%
dplyr::filter(Phase == "Primary", !is.na(CatchmentGroup)) %>%
dplyr::select(School, CatchmentGroup),
by = "School"
) %>%
dplyr::group_by(Year, CatchmentGroup) %>%
dplyr::summarise(Primary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop")
secondary_by_catch <- secondary_all %>%
dplyr::filter(School != "Total", !is.na(Total_offer)) %>%
dplyr::inner_join(
schools_with_catchment %>%
dplyr::filter(Phase == "Secondary", !is.na(CatchmentGroup)) %>%
dplyr::select(School, CatchmentGroup),
by = "School"
) %>%
dplyr::group_by(Year, CatchmentGroup) %>%
dplyr::summarise(Secondary_offers = sum(Total_offer, na.rm = TRUE),
.groups = "drop")
# --- Join cohort-aligned pairs: primary Y -> secondary Y+7 ---
LAG <- 7L
cohort_pairs <- primary_by_catch %>%
dplyr::mutate(Secondary_year = Year + LAG) %>%
dplyr::inner_join(secondary_by_catch,
by = c("Secondary_year" = "Year",
"CatchmentGroup" = "CatchmentGroup")) %>%
dplyr::rename(Primary_year = Year) %>%
dplyr::mutate(Ratio = Secondary_offers / Primary_offers)
# --- Retention ratio per catchment (mean over overlapping cohorts) ---
retention <- cohort_pairs %>%
dplyr::group_by(CatchmentGroup) %>%
dplyr::summarise(
retention_ratio = mean(Ratio, na.rm = TRUE),
retention_sd = sd(Ratio, na.rm = TRUE),
n_cohorts = dplyr::n(),
.groups = "drop"
)
knitr::kable(
retention %>%
dplyr::mutate(retention_ratio = round(retention_ratio, 3),
retention_sd = round(retention_sd, 3)),
col.names = c("Catchment", "Retention ratio (mean)", "SD across cohorts", "Cohorts"),
align = "lrrr",
caption = "Fitted Y7 / Reception retention ratio by catchment, cohorts 2014 Reception – 2019 Reception (i.e. Y7 2021 – Y7 2026)"
)
```
The **retention ratio** column is how many Y7 offers a catchment has historically ended up making for each Reception offer it made seven years earlier. A number below 1 means the catchment's primary-age cohorts typically *shrink* by the time they reach Y7 (net leakage to independents, Catholic schools, out-of-city, etc.); a number above 1 means the catchment's Y7 schools typically end up larger than their own primary intake (cross-catchment pulling-in, or PAN-constrained schools being filled from outside). For the Religious schools the ratio is well above 1 because only Catholic primaries are counted in that row on the primary side, while both Kings and CN sit on the secondary side — so the ratio mechanically picks up every non-Catholic-primary pupil who joins Kings at Y7.
```{r}
# --- Project 2027..2033 secondary offers from primary 2020..2026 ---
# Our primary offer data runs to 2026, so we can cohort-age through 2033.
# The council's Oct-25 forecast reaches 2032 — overlap is 2027-2032.
projection <- primary_by_catch %>%
dplyr::filter(Year >= 2020L, Year <= 2026L) %>%
dplyr::rename(Primary_year = Year) %>%
dplyr::mutate(Secondary_year = Primary_year + LAG) %>%
dplyr::left_join(retention %>% dplyr::select(CatchmentGroup, retention_ratio),
by = "CatchmentGroup") %>%
dplyr::mutate(Projected_offers = round(Primary_offers * retention_ratio))
proj_wide <- projection %>%
dplyr::select(CatchmentGroup, Secondary_year, Projected_offers) %>%
tidyr::pivot_wider(names_from = Secondary_year, values_from = Projected_offers,
names_prefix = "Proj_")
# Council Oct-25 forecast (reshaped) for the six geographic catchments.
council_long <- bhcc_forecast %>%
dplyr::select(CatchmentGroup, PAN,
`Council_2026` = Y2026, `Council_2027` = Y2027,
`Council_2028` = Y2028, `Council_2029` = Y2029,
`Council_2030` = Y2030, `Council_2031` = Y2031,
`Council_2032` = Y2032)
compare_proj <- council_long %>%
dplyr::left_join(proj_wide, by = "CatchmentGroup") %>%
dplyr::select(CatchmentGroup, PAN,
Council_2027, Proj_2027,
Council_2028, Proj_2028,
Council_2029, Proj_2029,
Council_2030, Proj_2030,
Council_2031, Proj_2031,
Council_2032, Proj_2032,
Proj_2033)
knitr::kable(
compare_proj,
col.names = c("Catchment", "PAN",
"Council 2027", "Ours 2027",
"Council 2028", "Ours 2028",
"Council 2029", "Ours 2029",
"Council 2030", "Ours 2030",
"Council 2031", "Ours 2031",
"Council 2032", "Ours 2032",
"Ours 2033"),
align = "lrrrrrrrrrrrrrr",
format.args = list(big.mark = ","),
caption = "Council's Oct-25 forecast vs our cohort-aged projection, Y7 2027-2032 (+ our 2033 extension, beyond the council window)"
)
```
```{r}
# --- Projection chart: actuals + our projection + council forecast ---
# One line per series, facet by catchment.
# Long-format data for plotting: three series
series_actual <- secondary_by_catch %>%
dplyr::transmute(CatchmentGroup, Year, Offers = Secondary_offers,
Series = "Actual offers")
series_proj <- projection %>%
dplyr::transmute(CatchmentGroup, Year = Secondary_year,
Offers = Projected_offers,
Series = "Our projection (cohort-aged)")
series_council <- bhcc_forecast %>%
tidyr::pivot_longer(cols = c(Y2026, Y2027, Y2028, Y2029, Y2030, Y2031, Y2032),
names_to = "YearStr", values_to = "Offers") %>%
dplyr::mutate(Year = as.integer(sub("^Y", "", YearStr)),
Series = "Council forecast (Oct-25, Appendix 7)") %>%
dplyr::select(CatchmentGroup, Year, Offers, Series)
# Also bring in the Oct-24 forecast as a separate series so readers can see
# how the council's view has shifted between the two successive forecasts.
series_council_oct24 <- bhcc_forecast_oct24 %>%
tidyr::pivot_longer(cols = c(Y2025, Y2026, Y2027, Y2028, Y2029, Y2030, Y2031),
names_to = "YearStr", values_to = "Offers") %>%
dplyr::mutate(Year = as.integer(sub("^Y", "", YearStr)),
Series = "Council forecast (Oct-24, Appendix 6)") %>%
dplyr::select(CatchmentGroup, Year, Offers, Series)
series_all <- dplyr::bind_rows(series_actual, series_proj,
series_council, series_council_oct24) %>%
dplyr::filter(!is.na(CatchmentGroup))
# Keep the six geographic catchments + Religious schools (actuals + our proj
# only; council row doesn't publish a Religious schools demand line).
catch_order <- c("BACA",
"Hove Park / Blatchington Mill",
"Longhill",
"PACA",
"Patcham",
"Varndean / Dorothy Stringer",
"Religious schools")
series_all <- series_all %>%
dplyr::mutate(CatchmentGroup = factor(CatchmentGroup,
levels = intersect(catch_order,
unique(CatchmentGroup))))
# PAN reference lines per panel (only for the six geographic catchments)
pan_df <- bhcc_forecast %>%
dplyr::select(CatchmentGroup, PAN) %>%
dplyr::mutate(CatchmentGroup = factor(CatchmentGroup,
levels = levels(series_all$CatchmentGroup)))
p_proj <- ggplot(series_all,
aes(x = Year, y = Offers, colour = Series, group = Series,
text = paste0(Series,
"<br>Catchment: ", CatchmentGroup,
"<br>Year: ", Year,
"<br>Offers: ", format(Offers, big.mark = ",")))) +
geom_hline(data = pan_df, aes(yintercept = PAN),
linetype = "dotted", colour = "grey30", linewidth = 0.4) +
geom_line(linewidth = 0.8) +
geom_point(size = 1.4) +
scale_colour_manual(values = c(
"Actual offers" = "#1f78b4",
"Our projection (cohort-aged)" = "#33a02c",
"Council forecast (Oct-25, Appendix 7)" = "#e31a1c",
"Council forecast (Oct-24, Appendix 6)" = "#fb9a99"
)) +
facet_wrap(~ CatchmentGroup, ncol = 3, scales = "free_y") +
labs(title = "Y7 offers: actuals, our cohort-aged projection, and the council's two forecasts",
subtitle = "Dotted grey = catchment PAN; our projection = primary offers(Y) \u00d7 retention ratio as secondary offers(Y+7); pink = superseded Oct-24 forecast",
x = NULL, y = "Y7 offers",
colour = NULL) +
theme_minimal(base_size = 10) +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "top",
strip.text = element_text(face = "bold"))
```
```{r}
#| label: projections_by_catchment
#| column: page
#| fig-height: 9
#| fig-width: 14
#| out-width: 100%
ggplotly(p_proj, tooltip = "text") %>%
plotly::layout(
autosize = TRUE,
legend = list(
orientation = "v",
x = 0.68, y = 0.28,
xanchor = "left", yanchor = "top",
bgcolor = "rgba(255,255,255,0.7)",
bordercolor = "rgba(0,0,0,0.15)",
borderwidth = 1
)
)
```
```{r}
#| label: projections_by_catchment_xlsx
#| include: false
#| message: false
#| warning: false
# Build an Excel workbook that contains:
# * a README sheet explaining each series and column
# * a tidy long-format "Data (long)" sheet with every point the chart uses
# * one sheet per catchment with a wide table (Year, Actual offers,
# Our projection, Council Oct-25, Council Oct-24, PAN) and an embedded
# PNG of the single-catchment plot
# * a "Dashboard" sheet containing a PNG of the full faceted chart
# The file is written to docs/ so it is served alongside the rendered HTML.
if (requireNamespace("openxlsx", quietly = TRUE)) {
out_dir <- "docs"
if (!dir.exists(out_dir)) dir.create(out_dir, recursive = TRUE)
out_path <- file.path(out_dir, "projections_by_catchment.xlsx")
# --- Long and wide data ---------------------------------------------------
data_long <- series_all %>%
dplyr::arrange(CatchmentGroup, Series, Year) %>%
dplyr::mutate(Offers = round(Offers, 1))
data_wide <- data_long %>%
tidyr::pivot_wider(names_from = Series, values_from = Offers) %>%
dplyr::left_join(pan_df, by = "CatchmentGroup") %>%
dplyr::arrange(CatchmentGroup, Year)
# Reorder columns to a predictable layout
wide_cols <- c("CatchmentGroup", "Year",
"Actual offers",
"Our projection (cohort-aged)",
"Council forecast (Oct-25, Appendix 7)",
"Council forecast (Oct-24, Appendix 6)",
"PAN")
data_wide <- data_wide[, intersect(wide_cols, names(data_wide))]
# --- Workbook -------------------------------------------------------------
wb <- openxlsx::createWorkbook()
header_style <- openxlsx::createStyle(textDecoration = "bold",
fgFill = "#f0f0f0",
border = "bottom")
# README
openxlsx::addWorksheet(wb, "README")
readme <- data.frame(Field = c(
"Chart", "Source file",
"Actual offers",
"Our projection (cohort-aged)",
"Council forecast (Oct-25, Appendix 7)",
"Council forecast (Oct-24, Appendix 6)",
"PAN",
"Catchment groups",
"Note"
), Description = c(
"Y7 offers: actuals, cohort-aged projection, and the council's two forecasts (by catchment).",
"brighton_and_hove_allocations.qmd (chunk: projections_by_catchment).",
"Actual Y7 offers recorded in the document (2010, 2012, 2013-2026).",
"Primary Y6 offers for cohort Y multiplied by the mean historical retention ratio to secondary Y7 offers (Y+7).",
"Council's current forecast, published Oct-25 (Appendix 7), 2026-2032.",
"Council's previous forecast, published Oct-24 (Appendix 6), 2025-2031; retained for comparison.",
"Published Admission Number for the catchment (sum across schools in the catchment group).",
"BACA; Hove Park / Blatchington Mill; Longhill; PACA; Patcham; Varndean / Dorothy Stringer; Religious schools (Kings + CN).",
"For the Religious schools panel there is no single PAN; the PAN column is blank."
), stringsAsFactors = FALSE)
openxlsx::writeData(wb, "README", readme, headerStyle = header_style)
openxlsx::setColWidths(wb, "README", cols = 1:2, widths = c(42, 110))
# Data (long)
openxlsx::addWorksheet(wb, "Data (long)")
openxlsx::writeData(wb, "Data (long)", data_long, headerStyle = header_style)
openxlsx::setColWidths(wb, "Data (long)", cols = 1:4, widths = c(32, 8, 42, 10))
openxlsx::freezePane(wb, "Data (long)", firstRow = TRUE)
# Data (wide)
openxlsx::addWorksheet(wb, "Data (wide)")
openxlsx::writeData(wb, "Data (wide)", data_wide, headerStyle = header_style)
openxlsx::setColWidths(wb, "Data (wide)", cols = 1:ncol(data_wide),
widths = c(32, 8, rep(18, ncol(data_wide) - 2)))
openxlsx::freezePane(wb, "Data (wide)", firstRow = TRUE)
# Dashboard with full chart
openxlsx::addWorksheet(wb, "Dashboard")
openxlsx::writeData(wb, "Dashboard",
data.frame(Chart = "Y7 offers by catchment - actuals, our projection, council forecasts"),
headerStyle = header_style)
print(p_proj)
openxlsx::insertPlot(wb, "Dashboard", width = 14, height = 9,
startRow = 3, startCol = 1,
fileType = "png", units = "in", dpi = 150)
# One sheet per catchment, with native Excel chart
catchments <- levels(series_all$CatchmentGroup)
catchments <- catchments[catchments %in% unique(series_all$CatchmentGroup)]
# Openxlsx can't draw native Excel charts, so per-catchment sheets get an
# embedded PNG of that catchment's subset plot, plus the wide table for that
# catchment so the user can rebuild the chart natively if they want.
safe_sheet <- function(x) {
x <- gsub("[\\\\/?*\\[\\]:]", "-", x, perl = TRUE)
substr(x, 1, 31)
}
for (cg in catchments) {
sheet_name <- safe_sheet(cg)
openxlsx::addWorksheet(wb, sheet_name)
cg_wide <- data_wide[data_wide$CatchmentGroup == cg, , drop = FALSE]
openxlsx::writeData(wb, sheet_name, cg_wide, headerStyle = header_style)
openxlsx::setColWidths(wb, sheet_name, cols = 1:ncol(cg_wide),
widths = c(32, 8, rep(18, ncol(cg_wide) - 2)))
cg_series <- series_all %>% dplyr::filter(CatchmentGroup == cg)
cg_pan <- pan_df %>% dplyr::filter(CatchmentGroup == cg)
p_cg <- ggplot(cg_series, aes(x = Year, y = Offers, colour = Series, group = Series)) +
{ if (nrow(cg_pan)) geom_hline(data = cg_pan, aes(yintercept = PAN),
linetype = "dotted", colour = "grey30",
linewidth = 0.4, inherit.aes = FALSE) } +
geom_line(linewidth = 0.9) +
geom_point(size = 1.8) +
scale_colour_manual(values = c(
"Actual offers" = "#1f78b4",
"Our projection (cohort-aged)" = "#33a02c",
"Council forecast (Oct-25, Appendix 7)" = "#e31a1c",
"Council forecast (Oct-24, Appendix 6)" = "#fb9a99"
)) +
labs(title = paste0("Y7 offers - ", cg),
subtitle = "Dotted grey = catchment PAN",
x = NULL, y = "Y7 offers", colour = NULL) +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom")
print(p_cg)
openxlsx::insertPlot(wb, sheet_name, width = 9, height = 5.2,
startRow = nrow(cg_wide) + 4, startCol = 1,
fileType = "png", units = "in", dpi = 150)
}
openxlsx::saveWorkbook(wb, out_path, overwrite = TRUE)
}
```
[Download the data and chart as Excel](projections_by_catchment.xlsx)
**Reading the chart:**
- Blue = actual Y7 offers we have in this document (2010, 2012, 2013–2026).
- Green = our cohort-aged projection (2027–2033, seven years ahead of the latest primary cohorts).
- Red = the council's current Oct-25 forecast (Appendix 7, published 2026–2032).
- Pink = the council's earlier Oct-24 forecast (Appendix 6, published 2025–2031), retained here so the revision between the two is visible.
- Dotted grey line = catchment PAN (not plotted for the Religious schools panel, which has no single PAN).
**The first thing to look at** is where the red (Oct-25) line sits relative to the pink (Oct-24) line: for almost every catchment the council has revised its view **downward** between the two forecasts. Where the revision was biggest — Longhill, Varndean / Stringer, Hove Park / Blatchington Mill — the Oct-25 forecast has moved partway towards our cohort-aged projection, though at Longhill it still sits well above what the 2025/2026 actuals and our projection imply.
**Catchment-by-catchment read (2027-2032 overlap):**
- **Longhill** — the clearest disagreement. Our projection is 17-34 % below the council's Oct-25 line in every year of the overlap (e.g. 2028: 112 vs 160; 2031: 98 vs 148). The Oct-25 revision lowered Longhill by \~5-15 pupils/year vs the Oct-24 view, but the gap to our method is still large and one-sided.
- **BACA** — our projection is 12-24 % below the council's in five of six overlap years, with 2032 the only year the two lines touch (108 vs 103). The Oct-25 Longhill-style revision didn't really happen at BACA — the council's line barely moved.
- **PACA** — our projection is 10-26 % below the council's in five of six overlap years, and identical in 2031 (both 147). Oct-25 revised PACA down modestly from Oct-24.
- **Hove Park / Blatchington Mill** — our projection is *above* the council's in every year of the overlap, by 3-24 %. This is the one catchment where we consistently forecast *more* demand than the council. The Oct-25 revision lowered the council's line here too, widening the gap.
- **Varndean / Dorothy Stringer** — the two methods are close everywhere (within ±4 % in every year). This was the catchment where Oct-24 most obviously disagreed with our projection; the Oct-25 revision has largely closed that gap.
- **Patcham** — also close, with the two methods within ±9 % in every year and no systematic direction.
- **Religious schools (Kings + CN)** — the council's Appendix 7 row for "Kings + CN" is 355 + 170 = 525/year flat (up from 330 + 167 = 497/year in Oct-24); our cohort-aged projection for the Religious schools row varies by cohort. No direct per-catchment line in the chart above for this since the council publishes only a flat 525 assumption.
## Whole-City Aggregate: Our Projection vs the Council's
Summing across all seven groups (six geographic catchments + Religious schools) gives a single city-wide view of our projection against the council's total-for-all-schools forecast. The council's "Total for all schools" row is directly comparable to a sum of our by-catchment projection, because the Religious schools row on our side is the Kings + CN component that the PDF has already built into its all-schools total.
```{r}
# City totals for actuals, our projection, council forecast (all schools).
city_actual <- secondary_by_catch %>%
dplyr::group_by(Year) %>%
dplyr::summarise(Offers = sum(Secondary_offers, na.rm = TRUE),
.groups = "drop") %>%
dplyr::mutate(Series = "Actual offers (all schools)")
city_proj <- projection %>%
dplyr::group_by(Year = Secondary_year) %>%
dplyr::summarise(Offers = sum(Projected_offers, na.rm = TRUE),
.groups = "drop") %>%
dplyr::mutate(Series = "Our projection (cohort-aged)")
# Council's "Total for all Schools" row, Appendix 7 (Oct-25), incl. Kings + CN
city_council <- tibble::tibble(
Year = 2026:2032,
Offers = c(2251L, 2215L, 2169L, 2087L, 2025L, 2013L, 1869L),
Series = "Council forecast (Total for all schools, Oct-25)"
)
# Retain the earlier Oct-24 forecast as a reference series so the revision is
# visible in the whole-city chart too.
city_council_oct24 <- tibble::tibble(
Year = 2025:2031,
Offers = c(2297L, 2284L, 2234L, 2206L, 2117L, 2028L, 2009L),
Series = "Council forecast (Total for all schools, Oct-24)"
)
city_all <- dplyr::bind_rows(city_actual, city_proj, city_council, city_council_oct24)
# Summary table for the six projection years where we have both series
# (2027-2032: our projection and the council's Oct-25 forecast overlap).
city_table <- dplyr::inner_join(
city_proj %>% dplyr::select(Year, Ours = Offers),
city_council %>% dplyr::select(Year, Council = Offers),
by = "Year"
) %>%
dplyr::arrange(Year) %>%
dplyr::mutate(
Diff = Ours - Council,
PctDiff = 100 * Diff / Council,
Year = as.character(Year) # avoid "2,027" from big.mark
)
knitr::kable(
city_table %>%
dplyr::mutate(PctDiff = round(PctDiff, 1)),
col.names = c("Year", "Ours", "Council", "Δ (ours − council)", "Δ %"),
align = "lrrrr",
format.args = list(big.mark = ","),
caption = "City-wide Y7 projection (2027-2032): ours vs council's Appendix 7 (Oct-25) total-for-all-schools"
)
```
```{r, fig.width=10, fig.height=5, out.width="100%"}
p_city <- ggplot(city_all,
aes(x = Year, y = Offers, colour = Series, group = Series,
text = paste0(Series,
"<br>Year: ", Year,
"<br>Offers: ", format(Offers, big.mark = ",")))) +
geom_line(linewidth = 1) +
geom_point(size = 2) +
scale_colour_manual(values = c(
"Actual offers (all schools)" = "#1f78b4",
"Our projection (cohort-aged)" = "#33a02c",
"Council forecast (Total for all schools, Oct-25)" = "#e31a1c",
"Council forecast (Total for all schools, Oct-24)" = "#fb9a99"
)) +
scale_x_continuous(breaks = seq(min(city_all$Year), max(city_all$Year), 1)) +
labs(title = "City-wide Y7 offers: actuals, our projection, and council forecasts",
subtitle = "Sum across all 9 secondary schools (6 catchments + Cardinal Newman + Kings); Oct-25 = current, Oct-24 = prior",
x = NULL, y = "Total Y7 offers",
colour = NULL) +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "top")
ggplotly(p_city, tooltip = "text") %>%
plotly::layout(legend = list(orientation = "V", x = 0.48, y = 0.05))
```
At the city level the **overall direction is the same on both methods and both forecasts** — a steady decline in aggregate Y7 demand from \~2,300 in the mid-2020s to well under 2,000 by the early 2030s. That's consistent with the shrinking primary cohorts on our side, and the council's census-based school-roll decline on theirs.
Two things are visible in the chart and table above:
1. **The council has revised its city-wide demand forecast downward between Oct-24 and Oct-25.** The Oct-24 forecast (pink) had aggregate demand of 2,206 in 2028 falling to 2,009 in 2031. The Oct-25 forecast (red) has 2,169 in 2028 falling to 2,013 in 2031 and 1,869 in 2032. So for the five-year overlap window 2027-2031 the council's own view has come down by roughly **50-100 pupils/year**, which narrows (but does not close) the gap to our cohort-aged projection.
2. **Our projection still sits at or below the council's Oct-25 forecast in every year of the overlap.** The gap is smaller than it was against the Oct-24 forecast, but our method still finds retention ratios low enough — especially at Longhill, BACA and PACA — that the city total lands below the council's demand forecast. Hove Park / Blatchington Mill is the only catchment where our projection runs above the council's, and not by enough to cancel out the undershoots elsewhere.
The practical implication: both methods project a substantial city-wide **surplus** of places by the early 2030s. Against a total PAN of **2,500** (catchment schools + Cardinal Newman + Kings), the council's Oct-25 forecast implies an aggregate surplus rising from \~250 places in 2026 to \~630 places (\~25 %) by 2032. Our method, applied only to 2027 onwards, lands in the same range for the overlap years. So the two methods agree on the overall surplus scale but disagree on where in the city it will fall hardest — and the council's Oct-25 revision has narrowed, but not eliminated, that disagreement.
## Are the Differences Methodological, or Just Noise?
When comparing two forecasts it's worth asking whether the gaps between them are **systematic** (driven by the structure of the methods and likely to persist) or **noisy** (small random fluctuations that would wash out over enough years). Both matter for how to read the forecasts, but they imply very different next steps. A systematic gap in a particular direction at a particular school is a signal for planners; noise is not.
Looking at the catchment-by-catchment numbers in the table above — now against the council's **Oct-25** forecast — three features still stand out as **systematic rather than noise**, and two further patterns have shifted compared to the Oct-24 comparison:
1. **The direction of the gap at Longhill is one-sided every single year.** Our projection is **17 %–34 % below** the council's in all six overlap years (2027: 107 vs 148; 2028: 112 vs 160; 2029: 104 vs 146; 2030: 96 vs 129; 2031: 98 vs 148; 2032: 83 vs 100). That range is narrower than it was against the Oct-24 forecast (39–43 %), reflecting the council's upward revision of the Longhill leakage rate from 22.61 % to 23.89 % — but the gap is still substantial and one-directional.
2. **BACA and PACA are consistently lower on our side.** Ours is below the council's in 10 of 12 BACA + PACA year-cells (with one tie at PACA 2031 and one small reversal at BACA 2032), with gaps typically 10–30 %.
3. **Hove Park / Blatchington Mill is consistently higher on our side.** Ours is above the council's in 6 of 6 overlap years, with gaps of 3–24 %. This is the only catchment where our projection systematically exceeds the council's.
4. **Varndean / Dorothy Stringer has largely been reconciled.** Under Oct-24 this catchment showed a consistent several-percent gap; under Oct-25 the two methods are within ±4 % in every year. The council's revision has closed most of that disagreement.
5. **Patcham is now mixed rather than systematic.** Our projection alternates between slightly above and slightly below the council's Oct-25 line, with no clear direction.
The SDs in the retention-ratio table (0.02–0.23) give another read: if the ratios were themselves noisy, we'd expect a roughly symmetric mixture of "ours is higher" and "ours is lower" across catchments and years. What we see instead — even after the council's Oct-25 revision — is a clear pattern per catchment, pointing to structural drivers.
### Why Longhill is the big one
Longhill is also the catchment where the council's **2025 and 2026 actuals undershot the Oct-24 forecast most dramatically** — 97 offers in 2025 against a forecast of 168, and 81 offers in 2026 against a forecast of 175. So the disagreement between our projection and the council's for 2027 onwards isn't theoretical: for the two years where both forecasts *could* be checked against actuals, **the council's Oct-24 method was substantially over-estimating Longhill demand and our method would have been much closer**.
The council has partially responded in the Oct-25 revision. The published Longhill leakage rate has been raised from **22.61 %** (Oct-24) to **23.89 %** (Oct-25), and the Longhill demand line has been pulled down year-by-year — 2027 now 148 (was 166 in Oct-24), 2028 now 160 (was 176), 2031 now 148 (was 154). But the underlying method is unchanged: take the current Oct-25 census for each year group (children now in Longhill's primary catchment and in R–Y6), apply a single leakage percentage, then subtract a fixed number going to CN/Kings. The revised 23.89 % is still the highest leakage figure in the city — so the council plainly is not blind to Longhill's difficulties — but it is *still much lower than what the actuals imply*. Backing out the implied leakage from the 2025/26 actuals gives \~65–70 %, i.e. roughly three times even the raised Oct-25 rate.
Put another way: the council's method carries a built-in assumption that the relationship between "children of secondary age resident in Longhill's catchment" and "Y7 offers actually made at Longhill High" is broadly stable, with a fixed percentage moving elsewhere. Our empirical-ratio method learns that this relationship has been steadily deteriorating across the six cohorts we have data for, and the 2025/26 actuals confirm that. The Oct-25 forecast has nudged the leakage assumption up by 1.28 percentage points, which helps at the margins, but if the real-world decline continues at the pace the 2021-2026 Y7 actuals suggest, both methods remain biased upward — the council's much more so.
### What this means in practice
It's hard to tell from outside the council whether this is (a) deliberately cautious forecasting — "let's not project too fast a decline in case we need the capacity" — or (b) a consequence of the method itself, where a fixed leakage percentage naturally lags when real-world leakage is rising. I think **(b) is the more likely explanation**, for two reasons:
- The 23.89 % Longhill leakage (raised from 22.61 % in the Oct-25 revision) is already the highest by a wide margin, which suggests the council has tried to calibrate for the specific problem.
- A method that decomposes census → fixed leakage % → fixed CN/Kings annual number will, by construction, propagate whatever the recent calibrated leakage was forward. If leakage is rising year-on-year (as Longhill's 2021–2026 actuals suggest it has been), the fitted percentage will lag behind reality until the calibration is revisited. The Oct-25 update has revisited it once — but only by 1.28 percentage points, against 2025/26 actuals implying a real rate around three times that. That's a structural property of the method, not a judgement call.
But the *effect* is the same either way. For planning purposes, decisions about Longhill's future — whether to reduce PAN, consolidate provision, adjust catchment boundaries — will be taken on the basis of forecast demand figures that **both our method and the 2025/26 actuals suggest are still optimistic by 30–50 pupils per year** even after the Oct-25 revision. Whether one calls this "hope" or "methodological lag" is partly a matter of framing, but the practical implication is that if the council's Appendix 7 surplus numbers feel uncomfortable already, the underlying surplus at Longhill is very likely larger still.
A similar but smaller version of this story applies to **BACA** and **PACA**, where our projection sits 10–30 % below the council's and the 2025/26 actuals again point in our direction (the 2026 BACA rebound notwithstanding).
**Summary**: the gaps are methodological, not noise. The direction of each gap is consistent with the structural features of each method — the council's fixed-leakage, census-driven approach tends to over-estimate demand in catchments where state-sector leakage is rising faster than the calibration assumes; our empirical-ratio approach picks that up automatically but is itself vulnerable if the future behaves very differently from the 2014–2019 cohorts. The Oct-25 revision has narrowed the disagreement at Varndean / Stringer and at the city-wide level, but at Longhill specifically the 2025 and 2026 actuals still clearly back our projection over the council's — suggesting the revised forecast is optimistic there too, not through hope, but through a method that struggles to keep up with an accelerating secular trend.
## Policy watch: Can removing Whitehawk from Longhill's catchment reverse the decline?
Following the 2024 Secondary Admissions Consultation, Whitehawk was removed from Longhill High's catchment. While is was never explicitly stated by the council, some on Social Media Forums have hypothesises that the Whitehawk ward, the most deprived ward in the city, disproportionately shapes how Longhill is perceived by other families in the catchment. Middle-class parents in Woodingdean, Rottingdean, Ovingdean and Saltdean are, if this theory holds, being driven away by reputation effects to send their children elsewhere — to Lewes Priory over the county line, or to Cardinal Newman and Kings across the city — at a higher rate than any other B&H catchment produces. Remove Whitehawk from the nominal catchment, the argument goes, the perceived demographic mix shifts, parent confidence recovers, and uptake rates follow. We cannot know if this was the policy intention, but it is certainly one possible hypothesis.
It is worth being explicit about what kind of claim this is: it is a *behavioural* hypothesis about how parents respond to a label, grafted on top of a *demographic* reality about catchment-resident pupil numbers. The data in this document can speak clearly to the demographic part and only indirectly to the behavioural part, so what follows separates the two.
### What the data does say
From the cohort-by-catchment chart above and the 2024 FOI crosscheck:
- **Longhill has the worst retention in the city by some margin.** In 2024, 263 Year 7 offers were made to Longhill-catchment children; 98 of those (**37 %**) were offers to Longhill High itself. Every other geographic catchment retained 60–92 % of its own offers in-catchment. The rest of Longhill's 2024 flow was: **64 to Cardinal Newman (\~24 %)**, **38 to Priory School in Lewes (\~14 %)**, **27 to BACA**, **12 to Dorothy Stringer**, **8 to Hove Park**, and a long tail. Only **\~19** of the catchment's offers (≈ 7 %) were lost to the independent sector outright.
- **The same-cohort gap is deepening year on year.** Reception-to-Y7 offers for the Longhill catchment have moved further into deficit across every cohort we have data for, from modest net loss for the 2014 cohort to sharp net loss for the 2019 cohort (Y7 2026).
- **The underlying primary-age population is shrinking.** The council's own Oct-25 census for the Longhill primary catchment shows fewer children in every R–Y6 year group than the equivalent figure from the Oct-24 revision, and year groups currently in Key Stage 1 are smaller still. This is a demographic fact about the resident base, independent of any catchment-boundary decision.
- **The religious-schools flow is the largest non-catchment outflow for every catchment, not just Longhill.** What is distinctive about Longhill is not the direction of the outflow but the *rate*: \~24 % going to CN alone is roughly double what BACA or Patcham lose to CN+Kings combined.
### What the data cannot say
- The FOI data does not disaggregate by **ward within catchment**. We can see that Longhill catchment as a whole sends 24 % of its offers to Cardinal Newman and 14 % to Priory — but we cannot tell from this data whether those families are disproportionately Whitehawk residents, or Woodingdean / Rottingdean / Saltdean residents, or a broadly even mix. The ward-level split is exactly the question the policy hypothesis hinges on, and the public data doesn't answer it.
- The data also cannot say what Whitehawk parents themselves currently do. If Whitehawk families send their children to Longhill at *higher* rates than the rest of the catchment (plausible: fewer private alternatives, less car access, stronger proximity preference), then removing Whitehawk from the catchment *reduces* Longhill's intake base without producing any offsetting behavioural shift from the remaining wards. If Whitehawk families send their children elsewhere at *higher* rates than the rest of the catchment, removing Whitehawk would have the opposite effect. Without ward-level data we cannot tell which of these two worlds we are in — and the two have opposite implications for whether the policy helps or hurts.
### The Whitehawk change doesn't sit in isolation: a staggered stack of admissions reforms pulling the same direction
The Whitehawk boundary change is the most visible — but not the first — of a **stack of recent B&H admissions reforms** that all operate on the same flow: peripheral-catchment pupils into central-catchment schools. Three other mechanisms are being phased in alongside it, each biting one admission round earlier than the next, so the combined effect builds cumulatively rather than arriving in one step. In order of when they first affect a Y7 intake:
1. **Out-of-catchment Free School Meals priority — first bites in the 2025 allocation round.** FSM-eligible children living *outside* a school's catchment are ranked above in-catchment non-FSM applicants in the oversubscription criteria for the four central-catchment schools. This gives pupils in deprived parts of peripheral catchments — Whitehawk conspicuously included — a formal route into a central-catchment school ahead of non-FSM children from that school's own catchment. The 2025 Y7 intake is the first cohort allocated under this rule.
2. **Open Allocation priority — first bites in the 2026 allocation round.** Five per cent of the Year 7 places at each of Varndean, Dorothy Stringer, Hove Park and Blatchington Mill are reserved for applicants resident in the four **single-school catchments** on the edge of the city (BACA, PACA, Patcham, Longhill). On combined central-catchment PANs of roughly 1,170, that is around **58 Open Allocation places per year**, shared across four peripheral origins. Longhill's notional share is in the order of 14 places a year, but the allocation is oversubscription-driven and will redistribute to whichever peripheral catchments actually apply. The 2026 Y7 intake is the first cohort to include OA places.
3. **Out-of-catchment sibling priority — first bites in the 2027 allocation round.** Once an Open Allocation pupil has been admitted, a younger sibling applying in a later year gets priority over in-catchment non-sibling applicants. In typical B&H admissions arithmetic, siblings account for around 25 % of incoming Y7 offers, so as successive OA cohorts start producing younger siblings entering Y7 the effective peripheral-to-central share stops being "5 % of places" and drifts upward — plausibly towards **close to 10 % in steady state** once several cohorts have passed through. The first OA cohort admitted in 2026 is the source-cohort for the first sibling admissions in 2027; the effect grows from there.
4. **Whitehawk catchment removal — first bites in the 2026 allocation round.** Removes a ward from Longhill's nominal catchment directly. First-hand accounts suggest roughly **a third** of Whitehawk families still listed Longhill among their preferences in the 2026 round — historical inertia, older siblings already at the school, proximity, familiarity — so the boundary change bites hard *on paper* but softly in the first year of data. Whether that residual Whitehawk-to-Longhill share fades further as cohorts without older siblings at Longhill come through, or stabilises at some floor, is one of the open questions for the 2027 intake.
These four mechanisms do not add up independently — OA, sibling and FSM priorities can stack on the same individual applicant — but their *directional* effect is unambiguous: over the next few admission rounds they will progressively lift the share of central-catchment places going to peripheral-catchment children, and they will progressively reduce the in-catchment retention denominator for Longhill, BACA, PACA and Patcham.
The staggered timing matters for how we read the data:
- **The 2024 FOI is a pre-policy baseline.** None of the reforms above had yet bitten the 2024 Year 7 intake. The \~25–30 offers going from Longhill catchment to the four central-catchment schools in 2024 (Dorothy Stringer 12, Hove Park 8, plus small numbers to BMS and Varndean — about 10 % of the catchment's offers) was happening *before* FSM priority, *before* Open Allocation, and *before* the sibling uplift existed. That is a useful reference point: even under the old rules, around one in ten Longhill-catchment pupils was already ending up at a central-catchment school through the general ranking of preferences. The new reforms sit on top of that flow, not in place of it.
- **The 2025 and 2026 Longhill Y7 actuals are the first two ticks of the new policy stack.** Longhill's Y7 offer count fell from **97 in 2025** to **81 in 2026**, a 16-pupil drop in one admissions round. The 2025 figure is the first intake under the FSM out-of-catchment priority, with Whitehawk still formally in the Longhill catchment. The 2026 figure is the first intake under Open Allocation *and* the first intake after the Whitehawk removal — though, as noted above, around a third of Whitehawk families continued to name Longhill in 2026 despite the boundary change. The 16-pupil drop is therefore a combined read of at least three concurrent effects: the FSM channel widening in its second year, OA opening for the first time, and Whitehawk partially leaving. We cannot attribute the 2025-to-2026 step cleanly to any single reform from the public data — cohort-size change and random variation also contribute — but the direction and order of magnitude match what the policies were designed to do.
- **2027 onwards is when the stack matures.** OA-linked sibling admissions begin, the residual Whitehawk-to-Longhill share comes under pressure as the older-sibling effect fades, and the Oct-25 council census shows smaller primary-catchment year groups working their way up the system. Each of these individually nudges Longhill's Y7 count downward; together they arrive in a compressed window.
This reframes two of the points in the next section. Point 3 below ("the next-biggest leakage is part-geographic, part-policy") needs its timing made explicit: through 2024, the non-faith flow out of the Longhill catchment was **not** policy-driven, but from 2025 onwards a growing share of it will be. And point 4 needs strengthening on the same grounds: the residual non-Whitehawk Longhill catchment will not only inherit a longer track record of not choosing Longhill, it will face formal mechanisms pulling it into central catchment schools that weren't operating a year or two ago.
### Is there hope the policy will work? A reasoned take
On balance, and particularly in light of the wider policy stack described above, the data as we have it points to the Whitehawk change being **unlikely to reverse Longhill's decline**, while carrying a real risk of accelerating it. Five strands of evidence, in rough order of importance:
1. **Demography is the binding constraint.** The single biggest driver of Longhill's Year 7 numbers from 2027 onwards is the size of the 11-year-old resident population in the area, which is falling on every census the council publishes. A policy change that could, optimistically, lift the in-catchment retention rate by a few percentage points applies a *percentage* to a shrinking *base*. Even a generous retention uplift — say from 37 % to 50 % — applied to a catchment with fewer children in it each year, produces roughly the same absolute Year 7 number as today. The policy cannot undo demography.
2. **The biggest single leakage is to faith schools, and the boundary change doesn't touch that.** Cardinal Newman and Kings draw on **faith-based admissions criteria** and admit **city-wide**. Whether Whitehawk sits inside or outside the Longhill boundary does nothing to change how CN ranks applicants. If the 64 Longhill-to-CN flow in 2024 is a mix of genuinely Catholic families (for whom CN was always the first choice) and non-Catholic families exercising the reputation-driven switch the policy is trying to discourage, then the *first* group — which is probably the larger — is untouched by any catchment redraw. The policy targets a mechanism (perception-driven flight to CN) that is entangled with another mechanism (faith preference) the policy cannot influence.
3. **The next-biggest leakage is part-geographic, part-policy — and the policy part is growing.** The **38 Longhill-catchment offers to Priory School in Lewes** in 2024 almost certainly come from Saltdean families, for whom Priory is the closest secondary school by road, and is unaffected by any B&H policy at all. The pre-2025 flow into the central-catchment schools (Dorothy Stringer, Hove Park, Varndean, BMS) — roughly 25–30 offers in 2024 — was also essentially proximity-driven, happening under the old admissions rules. From 2025 onwards the FSM priority adds a second channel on top; from 2026 Open Allocation adds a third; from 2027 the sibling uplift starts compounding. None of these is blunted by the Whitehawk change.
4. **Removing Whitehawk has probably already begun to reduce the catchment's own retained intake.** Whitehawk has the lowest rates of private-sector take-up in the catchment (deprivation correlates strongly with state-sector uptake), the lowest rates of car ownership (which restricts the choice set to schools reachable on foot or by bus), and is further from Cardinal Newman, Kings and Priory than Woodingdean or Saltdean are. On any reasonable prior, Whitehawk was the part of the Longhill catchment most likely to have *already been* sending its children to Longhill pre-2025 — the FSM priority introduced in 2025 will have given its most deprived families a new route out to a central-catchment school ahead of that, but for non-FSM Whitehawk families it was still a short walk or bus ride to Longhill and a much longer journey anywhere else. The mechanical effect of the 2026 Whitehawk removal is to **strip out exactly the part of the catchment that was propping up Longhill's numbers**, leaving a residual catchment that is more affluent on paper but has a longer track record of *not* choosing Longhill. The softening observation is that **around a third of Whitehawk families still named Longhill in the 2026 round** despite being out of catchment — inertia, sibling links and proximity produced a cushion in year one. That cushion is unlikely to be stable: cohorts without an older sibling already at the school will face a harder choice architecture, and the OA plus sibling priority will offer them a formal alternative that their older brothers and sisters didn't have. The 2025 figure of 97 offers (Whitehawk still in catchment) and the 2026 figure of 81 offers (Whitehawk out of catchment, but with inertial residual take-up) are the first two years of this transition; numbers for later intakes, with the inertia fading and the OA sibling uplift biting, plausibly land lower, not higher.
5. **The policy stack is cumulative, and the tailwind runs against Longhill.** FSM out-of-catchment priority first bites in 2025; Open Allocation and Whitehawk removal both first bite in 2026; OA-sibling priority adds from 2027 onwards. Each is individually modest. Stacked, they all subtract from the same number — the count of Longhill-catchment children who end up at Longhill High — and they all compound over admission rounds rather than resetting each year. The 2025→2026 Longhill Y7 drop (97→81) is already a *combined* read of three of the four levers, cushioned by the inertial one-third of Whitehawk families who continued to pick Longhill; 2027 onwards brings the sibling uplift in and erodes that cushion further. Any benefit from the Whitehawk change alone would have to swim against that tide.
There is a narrower version of the policy argument that the data cannot fully refute: it is possible that Whitehawk's inclusion in the catchment is *such* a strong signalling effect that removing it produces a behavioural change among the Woodingdean / Rottingdean / Ovingdean / Saltdean wards large enough to more than offset both the direct intake loss and the ongoing drip from the OA / FSM / sibling stack. This would require, in round numbers, something like a 20–30 percentage-point uplift in uptake among the remaining catchment wards within a few years. There is no precedent in the B&H data (or, to our knowledge, in published evidence from other English local authorities using catchment changes to manage reputation) that movements of that size are achievable from a boundary label alone. Reputation recovers when the school's offer changes — outcomes, Ofsted grades, leadership, curriculum — not when the catchment boundary is redrawn around it.
**The honest summary**, based on the data we have: the Whitehawk change is a marginal behavioural lever on a problem that is mostly demographic, mostly faith-school-driven, and increasingly *policy-driven by the council's own staggered admissions reforms* pulling Longhill-catchment pupils into central-catchment schools — FSM priority from 2025, Open Allocation from 2026, OA-sibling compounding from 2027. The Oct-25 forecast, which already assumes a rising leakage rate, still undershoots observed reality by 30–50 pupils a year at Longhill, and was produced before the sibling uplift has begun to show in the data. The four policy levers described above share the same direction of travel — away from Longhill — and the sibling component makes the trend cumulative. A catchment redraw does not, by itself, make any of those 30–50 pupils reappear. Reversing Longhill's decline, if it is reversible at all, would need a package of changes acting on the *offer* (the school's outcomes and character) alongside and independent of the boundary and admissions reforms — and in the meantime the honest expected trajectory is continued Year 7 offers in the **80–110 range, trending downward**, against a PAN that is substantially larger, for the remainder of the decade.
That is not a reason to oppose the Whitehawk change specifically — the equity case for not using a single deprived ward as a branding device for a school it doesn't actually attend in great numbers is strong regardless of the population dynamics, and Open Allocation plus the FSM priority give Whitehawk children a positive route *into* the central-catchment schools that is arguably better for them than a notional place at a shrinking Longhill. But it is a reason to be cautious about presenting any of these four policy levers — individually or collectively — as a *recovery plan* for Longhill. The data does not support the hope that the label, or the admissions rulebook, will do the work on their own.
# Maps — Applications and Offers as Proportional Circles
School locations are taken from [Get Information about Schools](https://get-information-schools.service.gov.uk/) (GIAS). Catchment boundaries reflect the 2025/26 arrangements. Circle radii are scaled as `count^0.72 × k` — slightly above strict area-proportional (`count^0.5`) scaling to make differences between large and small schools more visually obvious while still being monotonic in the count.
Drag the year slider above each map to step through the time series. Each map has its own independent slider, so you can pin one map to a historic year while scrubbing through another. Click a school for the underlying figures.
```{r}
library(leaflet)
library(sf)
library(htmltools)
library(htmlwidgets)
library(jsonlite)
# ---- School coordinate lookup + optionZ polygons are defined earlier in
# the hidden `schools-catchments` setup chunk (just before the Interactive
# Time Series — Primary section), since they are shared by the primary
# by-catchment tabsets, the by-catchment cohort chart, and these maps.
catchment_colours <- c(
"BACA" = "#e41a1c", "HoveBlatchington" = "#377eb8", "Longhill" = "#4daf4a",
"PACA" = "#984ea3", "Patcham" = "#ff7f00", "VarndeanStringer" = "#a65628"
)
catchment_labels <- c(
"BACA" = "BACA (Brighton Aldridge)",
"HoveBlatchington" = "Hove Park / Blatchington Mill",
"Longhill" = "Longhill",
"PACA" = "PACA (Portslade Aldridge)",
"Patcham" = "Patcham",
"VarndeanStringer" = "Varndean / Dorothy Stringer"
)
pal_catch <- colorFactor(unname(catchment_colours), domain = names(catchment_colours))
# ---- Helper: join allocations to locations ----
prep_map_data <- function(long_df, extra_cols = NULL) {
base <- long_df |>
dplyr::filter(School != "Total") |>
dplyr::left_join(school_locs, by = "School")
missing <- base |> dplyr::filter(is.na(lat)) |> dplyr::pull(School) |> unique()
if (length(missing) > 0) warning("Missing coordinates for: ", paste(missing, collapse = ", "))
base |> dplyr::filter(!is.na(lat))
}
# ---- Radius scaling (square-root so area ~ value) ----
radius_scale <- function(x, k = 0.9) ifelse(is.na(x) | x <= 0, 0, sqrt(x) * k)
```
```{r}
# ---- Popup helpers ----
sec_popup_html <- function(d) {
sprintf(
"<b>%s</b><br>Year: %d<br>Applications (all prefs): <b>%s</b><br>Offers: <b>%s</b><br>1st-pref applications: %s<br>1st-pref offers: %s",
d$School, d$Year,
format(d$Total, big.mark = ","),
format(d$Total_offer, big.mark = ","),
format(d$No_1st_pref, big.mark = ","),
format(d$No_1st_pref_offer, big.mark = ",")
)
}
prim_popup_html <- function(d) {
fill_pct <- ifelse(is.na(d$PAN) | d$PAN == 0, NA_real_, 100 * d$Total_offer / d$PAN)
sprintf(
"<b>%s</b><br>Year: %d<br>PAN: %s<br>Applications (all prefs): <b>%s</b><br>Offers: <b>%s</b><br>Fill: %s%%<br>1st-pref applications: %s<br>1st-pref offers: %s",
d$School, d$Year,
ifelse(is.na(d$PAN), "–", as.character(d$PAN)),
format(d$Total, big.mark = ","),
format(d$Total_offer, big.mark = ","),
ifelse(is.na(fill_pct), "–", sprintf("%.0f", fill_pct)),
format(d$No_1st_pref, big.mark = ","),
format(d$No_1st_pref_offer, big.mark = ",")
)
}
legend_html <- function(title, swatch_colour, swatch_label) {
paste0(
'<div style="background:white;padding:6px 10px;border-radius:4px;',
'box-shadow:0 1px 4px rgba(0,0,0,0.25);font:12px sans-serif;max-width:220px;">',
'<b>', title, '</b><br>',
'<span style="display:inline-block;width:12px;height:12px;background:', swatch_colour,
';border-radius:50%;opacity:0.7;margin-right:4px;"></span>', swatch_label, '<br>',
'Circle area is proportional to the count.',
'</div>'
)
}
# ---- Build a full-width leaflet map with an external dropdown year picker ----
#
# This follows the working pattern from `bh-map-interactive` in
# school_attainment_tool/output/brighton_case_study.qmd: a bare leaflet map
# (tiles + setView only, plus optional catchment polygons), data shipped to
# the browser in a hidden <script type="application/json"> tag with a unique
# id, and onRender JS that reads the data, creates its own dropdown, and
# redraws L.circleMarker's at the correct radius every time the user picks
# a new year. No leaflet layer groups / no hideGroup/showGroup involved, so
# there's no way for "wrong-sized, already-drawn" circles to appear — every
# year's circles are freshly drawn at the year's counts.
build_year_map <- function(map_data, value_col, colour, fill_opacity, radius_k,
show_catchments, prefix, legend_title, legend_label,
is_primary = FALSE, height = 520,
radius_exp = 0.7, min_radius = 3) {
# radius_exp controls how dramatic the size differences are.
# Area-proportional scaling corresponds to exp = 0.5 (Math.sqrt); values
# above that push towards linear scaling and exaggerate contrast between
# small and large schools. 0.7 gives roughly a sqrt-of-sqrt boost to
# perceived differences without tipping into mis-reading (linear = 1.0
# would make the biggest school 10x wider than a school with 1/10th the
# count). min_radius keeps tiny circles visible rather than vanishing.
years <- sort(unique(map_data$Year))
default_year <- max(years)
data_id <- paste0("data_", prefix)
# Only the columns the JS popup / radius code needs. One row per
# school-year. Ensure PAN column exists even for secondary data so the
# toJSON call has a consistent schema across maps.
df <- map_data
if (!"PAN" %in% names(df)) df$PAN <- NA_real_
d_json <- df |>
dplyr::transmute(
Year = as.integer(Year),
School,
lat, lon,
value = .data[[value_col]],
Total,
Total_offer,
No_1st_pref,
No_1st_pref_offer,
PAN
) |>
dplyr::filter(!is.na(lat), !is.na(lon))
data_json <- jsonlite::toJSON(d_json, na = "null", dataframe = "rows")
# Bare leaflet — no markers added in R. Catchments are permanent polygons.
m <- leaflet(width = "100%", height = height) |>
addProviderTiles(providers$CartoDB.Positron) |>
setView(lng = -0.155, lat = 50.835, zoom = 12)
if (show_catchments) {
m <- m |> addPolygons(
data = optionZ,
fillColor = ~pal_catch(catchment), fillOpacity = 0.10,
color = ~pal_catch(catchment), weight = 2, opacity = 0.7,
label = ~catchment_labels[catchment]
)
}
# Hidden data tag, read by the JS on render. Unique id per map.
data_tag <- htmltools::tags$script(
type = "application/json",
id = data_id,
htmltools::HTML(as.character(data_json))
)
# Build the onRender JS via placeholder substitution (safer than sprintf
# when the JS body contains lots of % and format-like characters).
js_template <- "
function(el, x) {
var map = this;
var DATA_ID = '__DATA_ID__';
var years = __YEARS__;
var defaultYear = __DEFAULT_YEAR__;
var colour = '__COLOUR__';
var fillOpacity = __FILL_OPACITY__;
var radiusK = __RADIUS_K__;
var radiusExp = __RADIUS_EXP__;
var minRadius = __MIN_RADIUS__;
var legendTitle = '__LEGEND_TITLE__';
var legendLabel = '__LEGEND_LABEL__';
var isPrimary = __IS_PRIMARY__;
var dataEl = document.getElementById(DATA_ID);
if (!dataEl) return;
var all = JSON.parse(dataEl.textContent);
var markers = L.layerGroup().addTo(map);
// Legend control (bottom-left)
var legCtrl = L.control({position: 'bottomleft'});
var legDiv;
legCtrl.onAdd = function() {
legDiv = L.DomUtil.create('div');
legDiv.style.cssText = 'background:white;padding:8px 12px;' +
'border-radius:5px;box-shadow:0 1px 5px rgba(0,0,0,.25);' +
'font:12px sans-serif;max-width:240px;';
return legDiv;
};
legCtrl.addTo(map);
function fmt(v) {
if (v === null || v === undefined || isNaN(v)) return '\u2013';
return Number(v).toLocaleString();
}
function setLegend(year, n) {
if (!legDiv) return;
legDiv.innerHTML =
'<strong>' + legendTitle + ' — ' + year + '</strong><br>' +
'<span style=\"display:inline-block;width:12px;height:12px;background:' + colour +
';border-radius:50%;opacity:' + fillOpacity + ';margin-right:4px;\"></span>' +
legendLabel + '<br>' +
'<em style=\"font-size:11px;color:#666\">Circles sized by count · ' + n + ' schools</em>';
}
function popup(d) {
var html = '<b>' + d.School + '</b><br>Year: ' + d.Year + '<br>';
if (isPrimary && d.PAN !== null && d.PAN !== undefined) {
html += 'PAN: ' + fmt(d.PAN) + '<br>';
}
html += 'Applications (all prefs): <b>' + fmt(d.Total) + '</b><br>' +
'Offers: <b>' + fmt(d.Total_offer) + '</b><br>';
if (isPrimary && d.PAN && d.PAN > 0 &&
d.Total_offer !== null && d.Total_offer !== undefined) {
var pct = 100 * d.Total_offer / d.PAN;
html += 'Fill: ' + pct.toFixed(0) + '%<br>';
}
html += '1st-pref applications: ' + fmt(d.No_1st_pref) + '<br>' +
'1st-pref offers: ' + fmt(d.No_1st_pref_offer);
return html;
}
function draw(year) {
markers.clearLayers();
var rows = all.filter(function(d){ return String(d.Year) === String(year); });
var n = 0;
rows.forEach(function(d) {
if (d.lat == null || d.lon == null) return;
if (d.value == null || isNaN(d.value) || d.value <= 0) return;
var r = Math.pow(d.value, radiusExp) * radiusK;
if (r < minRadius) r = minRadius;
L.circleMarker([d.lat, d.lon], {
radius: r,
fillColor: colour,
fillOpacity: fillOpacity,
color: colour,
weight: 1.3
})
.bindPopup(popup(d))
.bindTooltip(d.School)
.addTo(markers);
n++;
});
setLegend(year, n);
}
// Slider, inserted directly before the map element.
// The slider is a plain HTML range input (no crosstalk, no shiny).
// The year label next to it updates as the user drags, and draw()
// is called on every input event so circles resize live.
var wrap = document.createElement('div');
wrap.style.cssText = 'margin:8px 0 12px 0;font:14px sans-serif;' +
'display:flex;align-items:center;gap:12px;flex-wrap:wrap;';
var lbl = document.createElement('label');
lbl.innerHTML = '<strong>Year:</strong>';
wrap.appendChild(lbl);
var yMin = years[0];
var yMax = years[years.length - 1];
var sld = document.createElement('input');
sld.type = 'range';
sld.min = String(yMin);
sld.max = String(yMax);
sld.step = '1';
sld.value = String(defaultYear);
sld.style.cssText = 'flex:1;min-width:240px;max-width:520px;' +
'accent-color:' + colour + ';';
wrap.appendChild(sld);
var val = document.createElement('span');
val.style.cssText = 'font-weight:bold;min-width:3.5em;text-align:right;';
val.textContent = String(defaultYear);
wrap.appendChild(val);
// Min/max endpoint labels under the slider
var ends = document.createElement('div');
ends.style.cssText = 'width:100%;display:flex;justify-content:space-between;' +
'font-size:11px;color:#666;margin-top:-4px;';
var endMin = document.createElement('span'); endMin.textContent = String(yMin);
var endMax = document.createElement('span'); endMax.textContent = String(yMax);
ends.appendChild(endMin);
ends.appendChild(endMax);
el.parentNode.insertBefore(wrap, el);
el.parentNode.insertBefore(ends, el);
function onSlide() {
var y = +sld.value;
val.textContent = String(y);
draw(y);
}
sld.addEventListener('input', onSlide);
sld.addEventListener('change', onSlide);
draw(defaultYear);
}
"
js_code <- js_template
js_code <- gsub("__DATA_ID__", data_id, js_code, fixed = TRUE)
js_code <- gsub("__YEARS__", as.character(jsonlite::toJSON(years)), js_code, fixed = TRUE)
js_code <- gsub("__DEFAULT_YEAR__", as.character(default_year), js_code, fixed = TRUE)
js_code <- gsub("__COLOUR__", colour, js_code, fixed = TRUE)
js_code <- gsub("__FILL_OPACITY__", format(fill_opacity, nsmall = 2), js_code, fixed = TRUE)
js_code <- gsub("__RADIUS_K__", format(radius_k, nsmall = 2), js_code, fixed = TRUE)
js_code <- gsub("__RADIUS_EXP__", format(radius_exp, nsmall = 2), js_code, fixed = TRUE)
js_code <- gsub("__MIN_RADIUS__", format(min_radius, nsmall = 1), js_code, fixed = TRUE)
js_code <- gsub("__LEGEND_TITLE__", legend_title, js_code, fixed = TRUE)
js_code <- gsub("__LEGEND_LABEL__", legend_label, js_code, fixed = TRUE)
js_code <- gsub("__IS_PRIMARY__", if (isTRUE(is_primary)) "true" else "false", js_code, fixed = TRUE)
m <- m |> htmlwidgets::onRender(js_code)
htmltools::browsable(htmltools::tagList(data_tag, m))
}
# ---- Prep data once ----
sec_map_data <- prep_map_data(secondary_all)
prim_map_data <- prep_map_data(primary_all)
```
## Secondary (Year 7) — Applications
```{r}
build_year_map(
map_data = sec_map_data,
value_col = "Total",
colour = "#1f78b4",
fill_opacity = 0.40,
radius_k = 0.28,
radius_exp = 0.72,
show_catchments = TRUE,
prefix = "sec_apps",
legend_title = "Applications",
legend_label = "Preferences received",
is_primary = FALSE,
height = 560
)
```
## Secondary (Year 7) — Offers
```{r}
build_year_map(
map_data = sec_map_data,
value_col = "Total_offer",
colour = "#e31a1c",
fill_opacity = 0.55,
radius_k = 0.32,
radius_exp = 0.72,
show_catchments = TRUE,
prefix = "sec_offers",
legend_title = "Offers",
legend_label = "Places allocated",
is_primary = FALSE,
height = 560
)
```
## Primary (Reception) — Applications
```{r}
build_year_map(
map_data = prim_map_data,
value_col = "Total",
colour = "#1f78b4",
fill_opacity = 0.35,
radius_k = 0.55,
radius_exp = 0.72,
show_catchments = FALSE,
prefix = "prim_apps",
legend_title = "Applications",
legend_label = "Preferences received",
is_primary = TRUE,
height = 600
)
```
## Primary (Reception) — Offers
```{r}
build_year_map(
map_data = prim_map_data,
value_col = "Total_offer",
colour = "#33a02c",
fill_opacity = 0.55,
radius_k = 0.75,
radius_exp = 0.72,
show_catchments = FALSE,
prefix = "prim_offers",
legend_title = "Offers",
legend_label = "Places allocated",
is_primary = TRUE,
height = 600
)
```
Notes on the maps:
- Each map has its own year slider. Dragging the slider redraws the circles on that map only — school **positions stay fixed**, but **radii change** because the circles are re-drawn from scratch at the selected year's counts. Radius is computed as `count^0.72 × k`: the exponent is slightly above strict area-proportional scaling (0.5) to exaggerate perceived differences between small and large schools, while still being monotonic and keeping cross-year comparisons honest.
- Secondary maps show the 2025/26 catchment polygons as a reference. Primary maps are plotted on a neutral basemap (no national primary catchments exist — primary admission uses home-to-school distance).
- West Hove Infant has two intake sites (Holland Road and Portland Road) plotted at their respective postcodes (BN3 1JL and BN3 5JA).
- Historic infant schools that amalgamated into primaries (Hangleton Infant, Hertford Infant) are plotted at their successor primary's location. Fully closed schools (Davigdor Infant to 2015, St Bartholomew's, St Peter's Community) are plotted at their original postcodes so pre-closure years render correctly.
# Notes on Data Quality
- Secondary figures include on-time applications only. Late applications and post-allocation adjustments are excluded.
- 2026 is the first secondary year reporting a 4th preference; earlier years show `NA` in 4th-preference columns in the combined dataset.
- Older primary factsheets (2014–2018) were retrieved from the Internet Archive (web.archive.org) as the council has removed them from the live site.
- School name harmonisation (e.g. *Hangleton Infant* → *Hangleton Primary*, *West Hove Connaught Road* → *West Hove Infant, Holland Road*) is applied in the combined dataset only; original per-year tables preserve the names as published.
- PAN values in the combined primary dataset are as published each year. PAN reductions (e.g. Balfour 120 → 90, Brunswick 120 → 90, Davigdor closure in 2015) are real and reflect council decisions.