The price of getting into London

Rail fares to the capital, weighed against the distance and time it takes to get there

Author

Adam Dennett

Published

July 9, 2026

What this document does

This analysis builds a station-level data panel of walk-up rail fares into London from the Rail Delivery Group (RDG/ATOC) national fares feed, joins it to station coordinates, and asks: relative to how far away a station is — and roughly how long the train takes — where is it cheap or expensive to commute from?

The motivating observations:

  • Brighton → London is priced differently by route: the “any permitted” fare (Victoria via the old Southern main line) costs more than the Thameslink-only fare into London Bridge / Blackfriars, for a journey of similar length.
  • Brighton vs Leicester: both are roughly an hour from a central London terminal, yet the annual season from Leicester to St Pancras costs about twice the Brighton one.

Data sources

Source What it provides Refresh route
RJFAF851 fares feed (this copy generated 25/05/2018) Every priced flow, fare and ticket type in GB Rail Data Marketplace → Fares data product (published by Rail Delivery Group). Download the latest RJFAFxxx.ZIP, unzip, and point params$feed_dir at it — the fixed-width format (spec SP0035 / RSPS5045) is unchanged.
UK stations.csv Station names, lat/long, CRS (TLC) and NLC codes Rail Data Marketplace station/location products, or the ORR estimates of station usage file
timetable/May18 Published Archive CIF.gz Fastest direct AM-peak journey time to each London terminal (via R/build_journey_times.R) Rail Data Marketplace → NWR Historical Timetable Data (May 2018 file matches this fares feed; May/Dec editions 2016–2025 available) or the RDG Timetable – Full Refresh products for current data
SP0035.pdf The record layout specification used for the parsers below

Fares shown are from May 2018. Relative differences between flows are remarkably persistent over time (regulated fares all move by the same cap), so the value conclusions are more durable than the price levels. To bring the whole panel up to date, drop in a current feed from the Marketplace.

How fares to “London” work in the feed

Three things make this non-trivial, and the code below handles all of them:

  1. London Terminals. Most fares are priced to the pseudo-location 1072 LONDON TERMINALS, not to a specific terminus. Each terminal’s location record carries fare_group = 1072, which is how we find them all. Some flows are priced to a specific terminal (e.g. Brighton → Victoria 5426, Brighton → Blackfriars 5112) — these are what create the route-level price differences.
  2. Clusters. Many fares are priced between station clusters (.FSC file), so a station inherits fares set for any cluster it belongs to, and likewise for its fare group.
  3. Seasons. The feed holds a 7-day season (7DS). Longer seasons are derived by the industry formula monthly = 3.84 × weekly-basis, annual = 40 × weekly-basis, where the basis is the PSS (season standard) fare if one exists, otherwise the 7DS fare. (Leicester is a live example: walk-up weekly 7DS = £270.00 but the annual is 40 × PSS £233.00 ≈ £9,320 — matching the published annual season.)
  4. The zone-priced belt. Stations on the Thameslink Bedford line (Harpenden, Luton, St Albans, Flitwick…) have their walk-up London fares priced not to 1072 but to zonal pseudo-destinations — LONDON ZONES 1-6 and friends — as Travelcards (ADT/ODT/AM3/WRE day tickets, 7TS weekly season). These are included and mapped to the matching categories; note a Travelcard also covers Tube and bus in the zones, so these stations get slightly more product for the money than terminal-only fares elsewhere.
Code
library(data.table)
library(ggplot2)
library(ggrepel)
library(geodist)
library(DT)
library(leaflet)
library(plotly)
library(crosstalk)

# interactive scatter with a station search box: wraps a ggplot built on a
# crosstalk SharedData so plotly's selectize search can highlight stations
searchable <- function(g, height = NULL) {
  ggplotly(g, tooltip = "text", height = height) |>
    highlight(on = "plotly_click", off = "plotly_doubleclick",
              selectize = TRUE, persistent = FALSE, color = "red")
}
library(knitr)
library(kableExtra)
library(scales)

feed_dir  <- params$feed_dir
feed_stem <- file.path(feed_dir, basename(feed_dir))   # e.g. .../RJFAF851/RJFAF851
stopifnot(file.exists(paste0(feed_stem, ".FFL")))

cache_dir <- file.path(dirname(feed_dir), "cache")
dir.create(cache_dir, showWarnings = FALSE)

# cache expensive parses keyed on feed version, so re-renders are fast and a
# new feed automatically invalidates the cache
cached <- function(name, expr) {
  f <- file.path(cache_dir, paste0(basename(feed_dir), "_", name, ".rds"))
  if (file.exists(f)) return(readRDS(f))
  x <- eval.parent(substitute(expr))
  saveRDS(x, f)
  x
}

ddmmyyyy <- function(s) as.IDate(s, format = "%d%m%Y")
fmin <- function(x) if (all(is.na(x))) NA_real_ else min(x, na.rm = TRUE)

# read a fixed-width feed file as raw lines, dropping /!! header comments
read_feed <- function(ext) {
  x <- fread(paste0(feed_stem, ".", ext), sep = NULL, header = FALSE,
             col.names = "line", quote = "", strip.white = FALSE)
  x[!startsWith(line, "/")]
}

# the feed's own generation date defines "today" for validity filtering
dat_hdr <- readLines(paste0(feed_stem, ".DAT"), n = 6, warn = FALSE)
as_of   <- as.IDate(sub(".*Generated:\\s+(\\d{2}/\\d{2}/\\d{4}).*", "\\1",
                        grep("Generated", dat_hdr, value = TRUE)[1]),
                    format = "%d/%m/%Y")
current <- function(dt) dt[start <= as_of & end >= as_of]

Feed RJFAF851, priced as at 25 May 2018.

Building the data panel

Locations, London terminals, clusters

Code
loc <- cached("loc", {
  x <- read_feed("LOC")[startsWith(line, "RL")]
  x <- x[, .(end   = ddmmyyyy(substr(line, 10, 17)),
             start = ddmmyyyy(substr(line, 18, 25)),
             nlc   = substr(line, 37, 40),
             name  = trimws(substr(line, 41, 56)),
             crs   = trimws(substr(line, 57, 59)),
             fare_group = substr(line, 70, 73))]
  unique(current(x), by = "nlc")
})

# the terminals group PLUS the zonal pseudo-destinations that include zone 1
# ("LONDON ZONES 1-x"): the Thameslink belt (Harpenden, Luton, St Albans,
# Flitwick...) has its London fares priced to these as Travelcards, not to 1072
london_nlc <- union(loc[fare_group == "1072" | nlc == "1072", nlc],
                    loc[grepl("^LONDON ZONES 1", name), nlc])

fsc <- cached("fsc", {
  x <- read_feed("FSC")
  current(x[, .(cluster = substr(line, 2, 5), member = substr(line, 6, 9),
                end   = ddmmyyyy(substr(line, 10, 17)),
                start = ddmmyyyy(substr(line, 18, 25)))])
})

london_clusters <- fsc[member %chin% london_nlc, unique(cluster)]
london_codes    <- union(london_nlc, london_clusters)

loc[nlc %chin% london_nlc, .(nlc, name, crs)] |>
  kable(caption = "Locations in the London Terminals fare group (1072)") |>
  kable_styling(full_width = FALSE)
Locations in the London Terminals fare group (1072)
nlc name crs
0032 LONDON ZONES 1-2
0033 LONDON ZONES 1-3
0034 LONDON ZONES 1-4
0035 LONDON ZONES 1-6
0064 LONDON ZONES 1-5
1072 LONDON TERMINALS
1444 LONDON EUSTON EUS
1475 LONDON MARYLEBNE MYB
1555 LONDON ST PANCRS STP
3087 LONDON PADDINGTN PAD
5112 LONDON BLKFRIARS BFR
5121 CITY THAMESLINK CTK
5142 LONDON CANNON ST CST
5143 LONDON CHARING X CHX
5148 LONDON BRIDGE LBG
5158 LONDON WATRLOO E WAE
5426 LONDON VICTORIA VIC
5597 VAUXHALL VXH
5598 LONDON WATERLOO WAT
6003 OLD STREET OLD
6005 MOORGATE MOG
6121 LONDON KINGS X KGX
6965 LONDON LIVRPL ST LST
7490 LONDON FENCH ST FST

Flows and fares into London

The .FFL file holds flow records (RF: origin, destination, route, validity) and fare records (RT: ticket code + price in pence). We keep every flow whose destination is a London code, plus reversible (direction = R) flows from a London code. One-directional flows out of London are deliberately excluded: contra-peak fares can be genuinely cheaper (a London→Brighton anytime day return was £26.20 against £41.90 towards London), so including them would understate commuter-direction prices.

Code
london_fares_raw <- cached("london_fares", {
  ffl <- read_feed("FFL")

  rf <- ffl[startsWith(line, "RF")]
  rf <- current(rf[, .(orig  = substr(line, 3, 6),  dest  = substr(line, 7, 10),
                       route = substr(line, 11, 15), direction = substr(line, 20, 20),
                       end   = ddmmyyyy(substr(line, 21, 28)),
                       start = ddmmyyyy(substr(line, 29, 36)),
                       toc   = substr(line, 37, 39), flow_id = substr(line, 43, 49))])

  rt <- ffl[startsWith(line, "RT")]
  rt <- rt[, .(flow_id = substr(line, 3, 9), ticket = substr(line, 10, 12),
               fare = as.integer(substr(line, 13, 20)),
               restriction = substr(line, 21, 22))]
  rm(ffl); gc(verbose = FALSE)

  inbound  <- rf[dest %chin% london_codes]
  outbound <- rf[orig %chin% london_codes & direction == "R"]
  setnames(outbound, c("orig", "dest"), c("dest", "orig"))
  flows <- rbind(inbound, outbound)

  merge(flows, rt, by = "flow_id", allow.cartesian = TRUE)[
    , .(orig, dest, route, toc, ticket, fare, restriction, source = "FFL")]
})

Non-derivable fare overrides

The .NFO file supplies fares that either aren’t in the flow file or override it (spec §6.3–6.4). We take adult, no-railcard records that are current and flagged usable (composite = "Y"), and let them override matching flow-file fares; suppressed records delete the flow-file fare.

Code
london_fares <- cached("london_fares_nfo", {
  nfo <- read_feed("NFO")
  nfo <- nfo[, .(orig = substr(line, 2, 5),   dest  = substr(line, 6, 9),
                 route = substr(line, 10, 14), railcard = substr(line, 15, 17),
                 ticket = substr(line, 18, 20),
                 end   = ddmmyyyy(substr(line, 22, 29)),
                 start = ddmmyyyy(substr(line, 30, 37)),
                 suppress = substr(line, 46, 46),
                 fare = as.integer(substr(line, 47, 54)),
                 composite = substr(line, 65, 65))]
  # NFO records are directional: keep only fares priced INTO London
  nfo <- current(nfo)[railcard == "   " & dest %chin% london_codes]

  suppressed <- nfo[suppress == "Y", .(orig, dest, route, ticket)]
  additions  <- nfo[suppress != "Y" & composite == "Y" & fare != 99999999L,
                    .(fare = min(fare)), by = .(orig, dest, route, ticket)][
                    , `:=`(toc = "NFO", restriction = "", source = "NFO")]

  out <- london_fares_raw
  if (nrow(suppressed))
    out <- out[!suppressed, on = c("orig", "dest", "route", "ticket")]
  # NFO overrides FFL on the same key
  out <- out[!additions, on = c("orig", "dest", "route", "ticket")]
  rbind(out, additions, fill = TRUE)
})

Ticket types, routes, and the fare categories we care about

Code
tty <- cached("tty", {
  x <- read_feed("TTY")
  x <- x[, .(ticket = substr(line, 2, 4),
             end   = ddmmyyyy(substr(line, 5, 12)),
             start = ddmmyyyy(substr(line, 13, 20)),
             tkt_desc = trimws(substr(line, 29, 43)),
             class = substr(line, 44, 44), type = substr(line, 45, 45))]
  unique(current(x), by = "ticket")
})

rte <- cached("rte", {
  x <- read_feed("RTE")[startsWith(line, "RR")]
  x <- x[, .(route = substr(line, 3, 7),
             end   = ddmmyyyy(substr(line, 8, 15)),
             start = ddmmyyyy(substr(line, 16, 23)),
             route_desc = trimws(substr(line, 32, 47)))]
  unique(current(x), by = "route")
})

# the standard-class walk-up ticket codes used nationally, plus the
# Travelcard codes used on the zone-priced flows (ADT/ODT/AM3/WRE day
# Travelcards, 7TS weekly Travelcard season)
categories <- data.table(
  ticket   = c("SDS","SDR","SOS","SOR","CDS","CDR","SVS","SVR","SSS","SSR","7DS","PSS",
               "ADT","ODT","AM3","WRE","7TS"),
  category = c("anytime_day_single","anytime_day_return",
               "anytime_single","anytime_return",
               "offpeak_day_single","offpeak_day_return",
               "offpeak_single","offpeak_return",
               "super_offpeak_single","super_offpeak_return",
               "weekly_season","season_basis",
               "anytime_day_return","offpeak_day_return",
               "super_offpeak_return","super_offpeak_return","weekly_season"))

fares_cat <- merge(london_fares, categories, by = "ticket")
fares_cat <- merge(fares_cat, rte[, .(route, route_desc)], by = "route", all.x = TRUE)

From fare codes to stations

A station can buy any fare priced against (a) its own NLC, (b) its fare group, or (c) any cluster containing either. We expand those memberships, then take the cheapest fare per station × category, remembering which route and London destination achieved it.

Code
stations <- loc[crs != "" & !nlc %chin% london_nlc & fare_group != "1072",
                .(nlc, name, crs, fare_group)]

# every code through which a station can inherit a fare
memb <- rbind(
  stations[, .(nlc, code = nlc)],
  stations[fare_group != nlc, .(nlc, code = fare_group)],
  merge(stations[, .(nlc, member = nlc)], fsc[, .(member, cluster)],
        by = "member")[, .(nlc, code = cluster)],
  merge(stations[, .(nlc, member = fare_group)], fsc[, .(member, cluster)],
        by = "member")[, .(nlc, code = cluster)]
) |> unique()

sf_long <- merge(memb, fares_cat, by.x = "code", by.y = "orig",
                 allow.cartesian = TRUE)
sf_long <- merge(sf_long, loc[, .(nlc, dest_name = name)],
                 by.x = "dest", by.y = "nlc", all.x = TRUE)

# cheapest record per station x category (+ route detail retained separately)
setorder(sf_long, fare)
best <- sf_long[, .SD[1], by = .(nlc, category),
                .SDcols = c("fare", "route", "route_desc", "dest", "dest_name")]

panel <- dcast(best, nlc ~ category, value.var = "fare")
num_cols <- setdiff(names(panel), "nlc")
panel[, (num_cols) := lapply(.SD, function(x) x / 100), .SDcols = num_cols]

# headline fares -------------------------------------------------------------
# peak     = best fully-flexible return (anytime day return or anytime return)
# off-peak = best restricted walk-up return (off-peak / super off-peak)
panel[, peak_return    := pmin(anytime_day_return, anytime_return, na.rm = TRUE)]
panel[, offpeak_return := pmin(offpeak_day_return, offpeak_return,
                               super_offpeak_return, na.rm = TRUE)]
panel[, peak_single    := pmin(anytime_day_single, anytime_single, na.rm = TRUE)]
panel[, offpeak_single := pmin(offpeak_day_single, offpeak_single,
                               super_offpeak_single, na.rm = TRUE)]
# annual season: 40 x the season basis (PSS if present, else the 7-day price)
panel[, annual_season  := 40 * fifelse(!is.na(season_basis), season_basis, weekly_season)]

# which route/terminal delivers the cheapest season
season_route <- best[category %in% c("season_basis", "weekly_season")]
setorder(season_route, fare)
season_route <- season_route[, .SD[1], by = nlc,
                             .SDcols = c("route_desc", "dest_name")]
panel <- merge(panel, season_route, by = "nlc", all.x = TRUE)

panel <- merge(stations[, .(nlc, station = name, crs)], panel, by = "nlc")

Geography: coordinates, distance, and (estimated) journey time

Code
uks <- fread(params$stations_csv)
uks <- uks[, .(station_name = Station, lat = Latitude, lon = Longitude,
               crs = TLC, nlc_csv = sprintf("%04d", as.integer(NLC)))]

panel <- merge(panel, unique(uks[, .(nlc_csv, lat, lon)]),
               by.x = "nlc", by.y = "nlc_csv", all.x = TRUE)
crs_fill <- unique(uks[!is.na(lat)], by = "crs")
panel[crs_fill, on = "crs",
      c("lat", "lon") := .(fcoalesce(lat, i.lat), fcoalesce(lon, i.lon))]

geo_matched <- panel[!is.na(lat), .N]

# straight-line distance to Charing Cross (the traditional London datum),
# with a 1.2 circuity factor to approximate route km
chx <- c(lon = -0.1247, lat = 51.5073)
panel[!is.na(lat),
      dist_km := as.numeric(geodist(cbind(lon, lat), t(chx),
                                    measure = "haversine")) / 1000]
panel[, rail_km := dist_km * 1.2]

# ---- journey time -----------------------------------------------------------
# If data/journey_times.csv exists (columns: crs, minutes) it is used.
# Otherwise we ESTIMATE time from distance: average speed rises with distance
# (stopping services near London, intercity further out). This is a placeholder
# to make the value analysis runnable end-to-end - see the appendix for how to
# replace it with real timetable times from the Rail Data Marketplace.
jt_file <- file.path(dirname(feed_dir), "data", "journey_times.csv")
panel[, est_speed_kmh := pmin(150, 50 + 0.5 * dist_km)]
panel[, time_min := rail_km / est_speed_kmh * 60]
panel[, `:=`(time_source = "estimated", time_terminal = NA_character_)]
if (file.exists(jt_file)) {
  jt <- fread(jt_file)
  if (!"terminal" %in% names(jt)) jt[, terminal := NA_character_]
  panel[jt, on = "crs", `:=`(time_min = i.minutes, time_source = "timetable",
                             time_terminal = i.terminal)]
}

Coordinates matched for 2527 of 2605 stations with London fares. Journey times are timetable-based where supplied, estimated elsewhere — treat time-based results as indicative until real timetable times are dropped in.

The finished panel

One row per GB station: peak/off-peak walk-up fares, weekly and annual seasons, the route that achieves the cheapest season, distance and journey time. Saved alongside this document as data/london_fare_panel.csv.

Code
setorder(panel, station)
dir.create(file.path(dirname(feed_dir), "data"), showWarnings = FALSE)
fwrite(panel, file.path(dirname(feed_dir), "data", "london_fare_panel.csv"))

panel[!is.na(annual_season) & !is.na(dist_km),
      .(Station = station, CRS = crs,
        `Peak rtn` = peak_return, `Off-peak rtn` = offpeak_return,
        `Weekly` = weekly_season, `Annual` = annual_season,
        `Via` = route_desc, `To` = dest_name,
        `Km` = round(dist_km), `Est mins` = round(time_min))] |>
  datatable(rownames = FALSE, filter = "top",
            options = list(pageLength = 10, scrollX = TRUE)) |>
  formatCurrency(c("Peak rtn", "Off-peak rtn", "Weekly", "Annual"),
                 currency = "£", digits = 2)

Analysis

Code
an <- panel[!is.na(annual_season) & !is.na(dist_km) & dist_km >= params$min_km]
an[, `:=`(annual_per_km   = annual_season / rail_km,
          annual_per_day  = annual_season / params$commuting_days,
          pence_per_min   = annual_season / (2 * params$commuting_days) / time_min * 100)]

highlight <- c("BRIGHTON", "LEICESTER", "READING", "CAMBRIDGE", "OXFORD",
               "WOKING", "MILTON KEYNES C", "CHELMSFORD", "SEVENOAKS",
               "PETERBOROUGH", "SWINDON", "GRANTHAM", "COLCHESTER",
               "TUNBRIDGE WELLS", "BASINGSTOKE", "BEDFORD", "LUTON")

Fares rise with distance — but on very different lines

Each point is a station’s cheapest annual season against its distance from central London. The vertical spread at any given distance is the interesting part: at ~60 km the annual season ranges over thousands of pounds depending on which line you happen to live on.

Code
d1 <- an[dist_km <= 200]
d1[, txt := sprintf("%s<br>£%s pa | %.0f km | %.0f min (%s)<br>cheapest via %s",
                    station, comma(annual_season), dist_km, time_min,
                    time_source, route_desc)]
sd1 <- highlight_key(d1, ~station, group = "Fare vs distance — search stations")
g1 <- ggplot(sd1, aes(dist_km, annual_season, text = txt)) +
  geom_point(alpha = 0.45, colour = "#4472a8", size = 1.4) +
  geom_smooth(aes(group = 1), method = "loess", se = FALSE,
              colour = "grey35", linewidth = 0.7) +
  scale_y_continuous(labels = label_currency(prefix = "£")) +
  labs(x = "Straight-line distance from central London (km)",
       y = "Annual season (cheapest route)") +
  theme_minimal()
searchable(g1)

Annual season cost vs straight-line distance from central London (stations ≥ 15 km, ≤ 200 km). Type in the search box to find and highlight a station; hover any point for detail.

Cost per kilometre: the commuter-belt premium map

Code
mp <- an[!is.na(lat) & dist_km <= 250]
mp[, ppk_pctile := frank(annual_per_km) / .N * 100]
pal <- colorNumeric("RdYlBu", c(0, 100), reverse = TRUE)
leaflet(mp) |>
  addProviderTiles(providers$CartoDB.Positron) |>
  addCircleMarkers(~lon, ~lat, radius = 5, stroke = FALSE, fillOpacity = 0.85,
                   fillColor = ~pal(ppk_pctile),
                   label = ~sprintf("%s — annual £%s, %.0f km, £%.2f/km",
                                    station, comma(annual_season),
                                    dist_km, annual_per_km / 100)) |>
  addLegend("bottomright", pal = pal, values = ~ppk_pctile,
            title = "£/km percentile")

Annual season pence-per-route-km. Blue = cheap for the distance, red = expensive.

Which stations are priced above or below the trend?

A simple model — log(annual season) ~ log(distance) — captures the broad distance gradient. The residual is then a clean measure of whether a station is expensive or cheap for how far out it is. (Once real journey times are loaded, refit against time for the sharper question.)

Code
m <- lm(log(annual_season) ~ log(dist_km), data = an)
an[, premium_pct := (exp(residuals(m)) - 1) * 100]

bind_tbl <- function(dt, cap) {
  dt[, .(Station = station, `Annual` = comma(annual_season, prefix = "£"),
         `Km` = round(dist_km),
         `Premium vs trend` = sprintf("%+.0f%%", premium_pct),
         `Cheapest via` = route_desc)] |>
    kable(caption = cap) |> kable_styling(full_width = FALSE)
}
setorder(an, premium_pct)
bind_tbl(head(an[dist_km <= 250], 15), "Best value: cheapest for their distance")
Best value: cheapest for their distance
Station Annual Km Premium vs trend Cheapest via
CATERHAM £1,220 25 -40% ANY - PERMITTED
TADWORTH £1,220 25 -40% ANY - PERMITTED
KINGSWOOD £1,220 24 -39% ANY - PERMITTED
BELMONT £1,012 19 -37% ANY - PERMITTED
TATTENHAM CORNER £1,220 24 -37% ANY - PERMITTED
MALDEN MANOR £932 17 -36% ANY - PERMITTED
WHYTELEAFE SOUTH £1,220 23 -36% ANY - PERMITTED
WORCESTER PARK £932 16 -35% ANY - PERMITTED
CHEAM £1,012 18 -35% ANY - PERMITTED
UPPER WARLINGHAM £1,220 22 -34% ANY - PERMITTED
CHIPSTEAD £1,220 22 -34% ANY - PERMITTED
WHYTELEAFE £1,220 22 -34% ANY - PERMITTED
EPSOM DOWNS £1,220 22 -33% ANY - PERMITTED
TEMPLECOMBE £6,536 169 -33% ANY PERMITTED
KENTON £932 16 -33% CLAPHAM JUNCTION
Code
bind_tbl(tail(an[dist_km <= 250], 15)[order(-premium_pct)],
         "Worst value: most expensive for their distance")
Worst value: most expensive for their distance
Station Annual Km Premium vs trend Cheapest via
EBBSFLEET INT SE £4,668 32 +89% PLUS HIGH SPEED
RADLETT £3,348 24 +71% ANY - PERMITTED
HARPENDEN £4,672 38 +64% ANY - PERMITTED
LUTON AIRPORT PW £5,216 45 +59% ANY - PERMITTED
LUTON £5,256 46 +56% ANY - PERMITTED
ST ALBANS CITY £3,712 30 +55% AAA ST ALBNS ABY
WATFORD JUNCTION £3,108 26 +50% ANY PERMITTED
ASCOT (BERKS) £4,464 40 +50% VIA STAINES
WATFORD NORTH £3,152 26 +49% ANY PERMITTED
GARSTON (HERTS) £3,152 27 +47% ANY PERMITTED
LEAGRAVE £5,256 50 +47% ANY - PERMITTED
OTFORD £3,500 31 +44% NOT VALID ON HS1
BRICKET WOOD £3,152 27 +44% ANY PERMITTED
FARNINGHAM ROAD £3,132 28 +42% NOT VALID ON HS1
HOW WOOD £3,152 28 +42% ANY PERMITTED

Weighing money against time

A season ticket buys access to London; time on the train is also a cost. Generalised cost is the standard transport-appraisal way (HM Treasury Green Book / DfT’s TAG guidance) of putting the two on one scale: convert travel time into money at a “value of time” and add it to the fare. In plain terms: take the annual season ticket, then add what a year’s worth of sitting on the train is worth — 2 journeys a day (out and back), times 230 commuting days a year, times the journey length in hours, times £13.1 per hour (the DfT’s appraisal value for commuters’ time, which you can and should vary to taste — the “Price or speed?” comparison further down shows how the rankings move when you do):

\[\text{generalised cost} = \text{annual season} + \underbrace{2 \times \text{days} \times \tfrac{\text{minutes}}{60} \times \text{VoT}}_{\text{a year of train time, in £}}\]

Code
an[, gen_cost := annual_season +
     2 * params$commuting_days * (time_min / 60) * params$vot_per_hour]

ggplot(an[dist_km <= 200], aes(time_min, annual_season)) +
  geom_point(aes(colour = gen_cost), alpha = 0.7, size = 1.8) +
  scale_colour_viridis_c(labels = label_currency(prefix = "£"),
                         name = "Generalised\nannual cost") +
  geom_text_repel(data = an[station %in% highlight & dist_km <= 200],
                  aes(label = station), size = 3, max.overlaps = 20) +
  scale_y_continuous(labels = label_currency(prefix = "£")) +
  labs(x = "Fastest direct AM-peak journey to a London terminal (minutes)",
       y = "Annual season") +
  theme_minimal()

Generalised annual commuting cost (fare + time valued at £13.10/hr). Timetable journey times where available, distance-based estimates otherwise.
Code
setorder(an, gen_cost)
an[dist_km <= 200][1:15,
   .(Station = station, `Annual` = comma(annual_season, prefix = "£"),
     `Est mins` = round(time_min),
     `Generalised cost` = comma(round(gen_cost), prefix = "£"))] |>
  kable(caption = "Lowest generalised annual commuting cost (≤ 200 km)") |>
  kable_styling(full_width = FALSE)
Lowest generalised annual commuting cost (≤ 200 km)
Station Annual Est mins Generalised cost
HARROW & WLDSTNE £1,168 14 £2,574
SOUTH CROYDON £1,012 17 £2,719
SOUTHALL £1,320 14 £2,726
NEW ELTHAM £1,400 14 £2,806
SUDBURY HILL £1,320 15 £2,826
SURBITON £1,196 17 £2,903
ELMSTEAD WOODS £1,400 15 £2,906
TWICKENHAM £1,168 19 £3,076
SEVEN KINGS £1,320 19 £3,228
SANDERSTEAD £1,220 20 £3,229
WORCESTER PARK £932 23 £3,242
GREENFORD £1,320 20 £3,294
NORTHOLT PARK £1,692 16 £3,299
BELMONT £1,012 23 £3,312
WADDON £1,012 23 £3,322

Generalised annual commuting cost (fare + time valued at £13.10/hr). Timetable journey times where available, distance-based estimates otherwise.

Time, distance and money together

Everything below uses the real May 2018 timetable times — the fastest direct train arriving at a London terminal between 06:30 and 10:30 — so it is restricted to stations with a direct AM-peak service.

Code
tt <- an[time_source == "timetable" & !is.na(time_min) & time_min > 0]
tt[, speed_kmh := dist_km / (time_min / 60)]     # straight-line speed
top_term <- tt[, .N, by = time_terminal][order(-N)][1:8, time_terminal]
tt[, term_grp := fifelse(time_terminal %chin% top_term, time_terminal, "Other")]

That leaves 726 stations. speed_kmh here is straight-line speed (distance as the crow flies over timetable minutes), so it understates track speed but is perfectly comparable between stations.

First, time: who gets fast trains?

Distance buys speed: stopping services dominate close to London, intercity expresses further out. The diagonal guides are straight-line speeds — stations sitting below the 100 km/h line get genuinely quick trains for their distance.

Code
guides_v <- data.table(v = c(50, 100, 150))
ggplot(tt[dist_km <= 250], aes(dist_km, time_min)) +
  geom_abline(data = guides_v, aes(slope = 60 / v, intercept = 0),
              linetype = "dashed", colour = "grey55") +
  annotate("text", x = c(155, 240, 240), y = c(196, 152, 90),
           label = paste(c(50, 100, 150), "km/h"), colour = "grey40", size = 3) +
  geom_point(aes(colour = term_grp), alpha = 0.7, size = 1.7) +
  scale_colour_brewer(palette = "Set2", name = "Arrives at") +
  geom_text_repel(data = tt[station %in% highlight & dist_km <= 250],
                  aes(label = station), size = 3, max.overlaps = 20) +
  coord_cartesian(ylim = c(0, 200)) +
  labs(x = "Straight-line distance from central London (km)",
       y = "Fastest direct AM-peak journey (minutes)") +
  theme_minimal()

Fastest direct AM-peak journey time vs distance, by arrival terminal. Guide lines show straight-line average speed.
Code
sp <- tt[dist_km >= 30]
setorder(sp, -speed_kmh)
sp[1:10, .(Station = station, `To` = time_terminal, `Mins` = time_min,
           `Km` = round(dist_km), `Speed km/h` = round(speed_kmh))] |>
  kable(caption = "Fastest stations for their distance (≥ 30 km)") |>
  kable_styling(full_width = FALSE)
Fastest stations for their distance (≥ 30 km)
Station To Mins Km Speed km/h
STAFFORD EUS 77 199 155
NEWCASTLE KGX 155 398 154
YORK KGX 113 281 149
RUGBY EUS 50 124 149
RUNCORN EUS 109 270 149
NUNEATON EUS 59 146 148
WAKEFIELD WESTGT KGX 106 260 147
NEWARK N GATE KGX 74 181 147
CREWE EUS 97 236 146
PETERBOROUGH KGX 49 119 146
Code
sp[.N:(.N - 9), .(Station = station, `To` = time_terminal, `Mins` = time_min,
                  `Km` = round(dist_km), `Speed km/h` = round(speed_kmh))] |>
  kable(caption = "Slowest stations for their distance (≥ 30 km)") |>
  kable_styling(full_width = FALSE)
Slowest stations for their distance (≥ 30 km)
Station To Mins Km Speed km/h
BAT & BALL BFR 64 33 31
LONGFIELD BFR 52 32 37
SOLE STREET BFR 60 38 38
MEOPHAM BFR 57 36 38
WINDSOR & E RIV VXH 51 33 39
KEMSING BFR 53 35 39
DATCHET VXH 48 32 40
HERTFORD EAST LST 49 33 40
FRIMLEY VXH 72 48 40
BOOKHAM VXH 45 30 40

The same information as a map — the companion to the cost-per-km map above. The fast corridors (blue) radiate along the intercity main lines; the slow fringes (red) are the stopping-service territories, most visibly the North Downs belt south-east of London.

Code
mp2 <- tt[!is.na(lat) & dist_km <= 250]
mp2[, sp_pctile := frank(speed_kmh) / .N * 100]
pal2 <- colorNumeric("RdYlBu", c(0, 100))
leaflet(mp2) |>
  addProviderTiles(providers$CartoDB.Positron) |>
  addCircleMarkers(~lon, ~lat, radius = 5, stroke = FALSE, fillOpacity = 0.85,
                   fillColor = ~pal2(sp_pctile),
                   label = ~sprintf("%s — %d min to %s, %.0f km, %.0f km/h",
                                    station, time_min, time_terminal,
                                    dist_km, speed_kmh)) |>
  addLegend("bottomright", pal = pal2, values = ~sp_pctile,
            title = "Speed percentile")

Straight-line speed of the fastest direct AM-peak train. Blue = fast for the distance, red = slow.

Does speed come at a price?

If fares priced what you actually get, faster corridors would cost more per kilometre. Plotting the price of distance against the speed of the journey shows how loosely they are related — the vertical scatter at any speed is money left on the table by some lines and extracted by others.

Code
tt[, dist_band := cut(dist_km, c(0, 50, 100, 250),
                      labels = c("< 50 km", "50–100 km", "> 100 km"))]
d2 <- tt[dist_km <= 250]
d2[, txt := sprintf("%s<br>£%.2f per km | %.0f km/h<br>£%s pa | %d min to %s",
                    station, annual_season / dist_km, speed_kmh,
                    comma(annual_season), time_min, time_terminal)]
sd2 <- highlight_key(d2, ~station, group = "Speed vs price — search stations")
g2 <- ggplot(sd2, aes(speed_kmh, annual_season / dist_km, text = txt)) +
  geom_point(aes(colour = dist_band), alpha = 0.65, size = 1.7) +
  geom_smooth(aes(group = 1), method = "loess", se = FALSE,
              colour = "grey30", linewidth = 0.7) +
  scale_colour_manual(values = c("#66c2a5", "#fc8d62", "#8da0cb"),
                      name = "Distance") +
  scale_y_continuous(labels = label_currency(prefix = "£")) +
  labs(x = "Straight-line speed of fastest AM-peak train (km/h)",
       y = "Annual season per straight-line km") +
  theme_minimal()
searchable(g2)

Annual season £ per straight-line km vs journey speed; colour = distance band. Searchable.

All three at once

Two views of the same triple. The bubble chart keeps money on the y-axis — the question a house-hunter asks — with distance as bubble size and speed as colour: big pale bubbles low on the chart are far-away, fast and cheap. The 3D scatter (drag to rotate) shows the full time × distance × cost surface; its interesting feature is the thickness of the cloud — at any (time, distance) there can be thousands of pounds of vertical spread.

Code
d3 <- tt[dist_km <= 250]
d3[, txt := sprintf("%s<br>£%s pa | %d min | %.0f km | %.0f km/h",
                    station, comma(annual_season), time_min, dist_km, speed_kmh)]
sd3 <- highlight_key(d3, ~station, group = "Time vs cost — search stations")
g3 <- ggplot(sd3, aes(time_min, annual_season, text = txt)) +
  geom_point(aes(size = dist_km, colour = speed_kmh), alpha = 0.6) +
  scale_colour_viridis_c(option = "plasma", name = "Speed (km/h)") +
  scale_size_continuous(range = c(1, 7), name = "Distance (km)") +
  scale_y_continuous(labels = label_currency(prefix = "£")) +
  labs(x = "Fastest direct AM-peak journey (minutes)",
       y = "Annual season (cheapest route)") +
  theme_minimal()
searchable(g3)

Annual season vs journey time; bubble size = distance, colour = straight-line speed. Searchable.

Code
plot_ly(tt[dist_km <= 250], x = ~dist_km, y = ~time_min, z = ~annual_season,
        color = ~term_grp, colors = "Set2",
        text = ~sprintf("%s<br>%.0f km | %d min | £%s pa<br>arrives %s via %s",
                        station, dist_km, time_min, comma(annual_season),
                        time_terminal, route_desc),
        hoverinfo = "text", type = "scatter3d", mode = "markers",
        height = 620, marker = list(size = 3.5, opacity = 0.75)) |>
  layout(scene = list(xaxis = list(title = "Distance (km)"),
                      yaxis = list(title = "Time (min)"),
                      zaxis = list(title = "Annual season (£)")))

Interactive: journey time × distance × annual season. Colour = arrival terminal.

The efficient frontier of commuting

A station is efficient if nowhere is both quicker and cheaper. Computed across the whole network that frontier collapses to a couple of cheap inner-suburban stations — correct, but unhelpful, because for a house-hunter distance is itself a benefit (space, house prices). So we treat distance as the third objective and draw the time–money frontier within distance bands: each step line is the rational menu for people who want to live roughly that far from London.

Code
tt[, band := cut(dist_km, c(15, 50, 100, 175, Inf),
                 labels = c("15–50 km", "50–100 km", "100–175 km", "175+ km"))]
fb <- tt[order(band, time_min, annual_season)]
fb[, keep := annual_season == cummin(annual_season), by = band]
frontier <- fb[keep == TRUE][, .SD[1], by = .(band, annual_season)]
ggplot(fb, aes(time_min, annual_season)) +
  geom_point(aes(colour = band), alpha = 0.25, size = 1.3) +
  geom_step(data = frontier, aes(colour = band), direction = "hv",
            linewidth = 0.7) +
  geom_point(data = frontier, aes(colour = band), size = 2.2) +
  geom_text_repel(data = frontier, aes(label = station), size = 2.7,
                  max.overlaps = 40) +
  scale_colour_brewer(palette = "Dark2", name = "Distance band") +
  scale_y_continuous(labels = label_currency(prefix = "£")) +
  coord_cartesian(xlim = c(0, 180)) +
  labs(x = "Fastest direct AM-peak journey (minutes)",
       y = "Annual season (cheapest route)") +
  theme_minimal()

Annual season vs journey time, with the Pareto frontier drawn within each distance band. Labelled stations are undominated: in their band nothing is both faster and cheaper.
Code
frontier[, .(Band = band, Station = station, `Mins` = time_min,
             `Annual` = comma(annual_season, prefix = "£"),
             `Km` = round(dist_km), `To` = time_terminal,
             `Cheapest via` = route_desc)] |>
  kable(caption = "Pareto-efficient stations, by distance band") |>
  kable_styling(full_width = FALSE, font_size = 12)
Pareto-efficient stations, by distance band
Band Station Mins Annual Km To Cheapest via
15–50 km HARROW & WLDSTNE 14 £1,168 17 EUS CLAPHAM JUNCTION
15–50 km SOUTH CROYDON 17 £1,012 16 LBG ANY - PERMITTED
15–50 km WORCESTER PARK 23 £932 16 VXH ANY - PERMITTED
50–100 km READING 26 £4,464 59 PAD ANY PERMITTED
50–100 km LETCHWRTH GN CTY 28 £4,216 53 KGX ANY - PERMITTED
50–100 km HITCHIN 30 £3,944 51 KGX ANY - PERMITTED
50–100 km BALCOMBE 37 £3,316 50 LBG THAMESLINK ONLY
50–100 km WENDOVER 46 £3,232 52 MYB CHALFONT & LATMR
50–100 km ERIDGE 63 £3,000 52 LBG ANY PERMITTED
50–100 km CROWBOROUGH 69 £2,924 56 LBG NOT UNDERGROUND
100–175 km PETERBOROUGH 49 £6,540 119 KGX GTNRTHRN&THMLINK
100–175 km BANBURY 57 £5,764 103 MYB ANY PERMITTED
100–175 km LONG BUCKBY 67 £5,604 110 EUS ANY PERMITTED
100–175 km ANDOVER 69 £4,784 101 WAT ANY PERMITTED
100–175 km SOUTHAMPTON CTL 76 £4,700 112 WAT THREE BRIDGES
175+ km NEWARK N GATE 74 £9,608 181 KGX EMIDSTRAINS.ONLY
175+ km RETFORD 86 £9,448 209 KGX HULL TRAINS ONLY
175+ km WOLVERHAMPTON 98 £8,380 182 EUS WMR & LNR ONLY
175+ km SHERBORNE 137 £7,016 178 WAT ANY PERMITTED

Annual season vs journey time, with the Pareto frontier drawn within each distance band. Labelled stations are undominated: in their band nothing is both faster and cheaper.

The best stations within a season-ticket budget

The frontier answers “what’s efficient overall”; a house-hunter usually asks the budget-constrained version: given what I can spend on the season ticket, where do I get the fastest train? First the leaderboards by price band, then the continuous version: how the fastest attainable commute falls as the budget rises, with the stations that set each new record.

Code
tt[, price_band := cut(annual_season, c(0, 2000, 3000, 4000, 5000, 7000, Inf),
                       labels = c("under £2k", "£2–3k", "£3–4k",
                                  "£4–5k", "£5–7k", "over £7k"))]
lead <- tt[order(time_min), .SD[1:8], by = price_band][!is.na(station)]
lead[, rank := seq_len(.N), by = price_band]
ggplot(lead, aes(time_min, 9 - rank)) +
  geom_segment(aes(xend = 0, yend = 9 - rank), colour = "grey85") +
  geom_point(aes(colour = dist_km), size = 3) +
  geom_text(aes(label = sprintf("%s — %d min, %.0f km, £%s",
                                station, time_min, dist_km,
                                comma(annual_season))),
            hjust = 0, nudge_x = 3, size = 2.7) +
  scale_colour_viridis_c(name = "Distance (km)") +
  facet_wrap(~price_band, ncol = 2) +
  scale_x_continuous(limits = c(0, 210)) +
  labs(x = "Fastest direct AM-peak journey (minutes)", y = NULL) +
  theme_minimal() +
  theme(axis.text.y = element_blank(), panel.grid.major.y = element_blank())

The fastest stations within each annual season price band. Colour = distance: pale, far-out stations high in a cheap band are the bargains.
Code
bc <- tt[order(annual_season, time_min)]
bc[, best_time := cummin(time_min)]
records <- bc[time_min == best_time][, .SD[1], by = time_min]
ggplot(bc, aes(annual_season, time_min)) +
  geom_point(alpha = 0.2, colour = "grey55", size = 1.2) +
  geom_step(aes(y = best_time), direction = "vh", colour = "firebrick",
            linewidth = 0.7) +
  geom_point(data = records, aes(y = time_min), colour = "firebrick",
             size = 2.2) +
  geom_text_repel(data = records,
                  aes(y = time_min,
                      label = sprintf("%s (%d min)", station, time_min)),
                  size = 2.8, max.overlaps = 30) +
  scale_x_continuous(labels = label_currency(prefix = "£"),
                     limits = c(0, 12000)) +
  coord_cartesian(ylim = c(0, 130)) +
  labs(x = "Annual season budget", y = "Fastest attainable journey (minutes)") +
  theme_minimal()

The fastest commute money can buy: minimum attainable journey time at each season-ticket budget. Labelled stations set a new speed record as the budget rises.

Price or speed? Shifting the balance

Whether a cheap-but-slow or fast-but-dear station “wins” depends on how much an hour on the train is worth to you. Re-ranking stations by generalised cost — fare plus 230 days of round-trip time valued at £λ/hour — shows the balance tipping as λ rises from zero (price is everything) to £50/hour (time is precious). Note this comparison is run for stations at least 40 km out: include the inner suburbs and they win at every λ, since they are both cheap and quick — the reason to move further out is housing, which is what the distance-banded frontier above captures. Out in the commuter belt proper, the tension is real: at λ = 0 the slow, cheap Oxted line dominates; by λ = £50 it has vanished in favour of the fast Thames Valley and East Coast corridors.

Code
vots <- c(0, 5, 13.10, 25, 50)
bal <- rbindlist(lapply(vots, function(v) {
  d <- tt[dist_km >= 40 & dist_km <= 250,
          .(station, annual_season, time_min, dist_km)]
  d[, gen := annual_season + 2 * params$commuting_days * (time_min / 60) * v]
  setorder(d, gen)
  d <- d[1:10]
  d[, `:=`(rank = seq_len(.N),
           vot = sprintf("λ = £%.0f/hr", v))]
  d
}))
bal[, vot := factor(vot, levels = sprintf("λ = £%.0f/hr", vots))]
ggplot(bal, aes(gen, 11 - rank)) +
  geom_segment(aes(xend = 0, yend = 11 - rank), colour = "grey85") +
  geom_point(aes(colour = time_min), size = 3) +
  geom_text(aes(label = sprintf("%s — £%s | %d min",
                                station, comma(round(gen)), time_min)),
            hjust = 0, nudge_x = 200, size = 2.6) +
  scale_colour_viridis_c(option = "magma", direction = -1,
                         name = "Journey (min)") +
  facet_wrap(~vot, ncol = 2, scales = "free_x") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.9))) +
  labs(x = "Generalised annual cost (fare + time)", y = NULL) +
  theme_minimal() +
  theme(axis.text.y = element_blank(), panel.grid.major.y = element_blank())

Top ten stations ≥ 40 km from London by generalised annual cost, under different values of time. £0/hr = cheapest fare wins; higher values increasingly reward speed.

Value for money, measured properly

With real times we can ask the sharper question: given how long the train takes and how far it goes, what should the season cost? Residuals from log(annual) ~ log(time) + log(distance) give each station’s premium or discount against the whole-network norm. (Time and distance are strongly correlated, so read the two elasticities jointly rather than separately.)

Code
m2 <- lm(log(annual_season) ~ log(time_min) + log(dist_km), data = tt)
tt[, premium2 := (exp(residuals(m2)) - 1) * 100]
cf <- coef(m2)
tbl2 <- function(dt, cap) {
  dt[, .(Station = station, `Annual` = comma(annual_season, prefix = "£"),
         `Mins` = time_min, `Km` = round(dist_km),
         `Premium vs norm` = sprintf("%+.0f%%", premium2),
         `Cheapest via` = route_desc)] |>
    kable(caption = cap) |> kable_styling(full_width = FALSE)
}
setorder(tt, premium2)
tbl2(head(tt[dist_km <= 250], 12), "Best value once time and distance are priced in")
Best value once time and distance are priced in
Station Annual Mins Km Premium vs norm Cheapest via
CATERHAM £1,220 44 25 -39% ANY - PERMITTED
MALDEN MANOR £932 24 17 -38% ANY - PERMITTED
WORCESTER PARK £932 23 16 -37% ANY - PERMITTED
UPPER WARLINGHAM £1,220 26 22 -36% ANY - PERMITTED
COULSDON SOUTH £1,220 21 21 -35% ANY - PERMITTED
WHYTELEAFE SOUTH £1,220 41 23 -35% ANY - PERMITTED
CHEAM £1,012 36 18 -34% ANY - PERMITTED
FELTHAM £1,196 26 21 -34% ANY - PERMITTED
SOUTH CROYDON £1,012 17 16 -33% ANY - PERMITTED
WHYTELEAFE £1,220 39 22 -33% ANY - PERMITTED
CHESSINGTON S £1,196 31 21 -33% ANY - PERMITTED
KENTON £932 31 16 -33% CLAPHAM JUNCTION
Code
tbl2(tail(tt[dist_km <= 250], 12)[order(-premium2)],
     "Worst value once time and distance are priced in")
Worst value once time and distance are priced in
Station Annual Mins Km Premium vs norm Cheapest via
RADLETT £3,348 32 24 +69% ANY - PERMITTED
HARPENDEN £4,672 33 38 +60% ANY - PERMITTED
ASCOT (BERKS) £4,464 50 40 +52% VIA STAINES
ST ALBANS CITY £3,712 28 30 +50% AAA ST ALBNS ABY
LUTON AIRPORT PW £5,216 24 45 +49% ANY - PERMITTED
LUTON £5,256 27 46 +47% ANY - PERMITTED
SHOREHAM KENT £3,256 57 29 +47% VIA SWANLEY
LEAGRAVE £5,256 47 50 +46% ANY - PERMITTED
FARNINGHAM ROAD £3,132 48 28 +45% NOT VALID ON HS1
BAT & BALL £3,500 64 33 +44% NOT VALID ON HS1
OTFORD £3,500 40 31 +44% NOT VALID ON HS1
MARTINS HERON £4,464 55 43 +43% VIA STAINES

The elasticities carry a striking message: season prices scale with distance (a 10% longer crow-fly distance means roughly +8.6% on the price), while journey time is barely priced at all — conditional on distance its elasticity is just -0.10, indistinguishable from “speed comes free”. Fares inherit British Rail’s mileage-based structure; the quality of the service — an express versus a crawl — was never systematically capitalised. That is exactly why fast-corridor stations (Leicester’s 67-minute St Pancras run priced like the long journey it is by distance) can coexist with Brighton’s similar-time, much cheaper season, and why the residual spread above is where the house-hunting opportunities live.

Case study: Brighton vs Leicester

Code
cs_nlc <- stations[name %in% c("BRIGHTON", "LEICESTER"), nlc]
cs <- sf_long[nlc %chin% cs_nlc &
              category %in% c("anytime_day_return", "offpeak_day_return",
                              "anytime_return", "offpeak_return",
                              "weekly_season", "season_basis")]
cs <- merge(cs, stations[, .(nlc, station = name)], by = "nlc")
cs_best <- cs[, .(fare = min(fare) / 100),
              by = .(station, category, route_desc, dest_name)]
dcast(cs_best, station + route_desc + dest_name ~ category,
      value.var = "fare") |>
  kable(caption = "Every priced route to London: Brighton vs Leicester (£)") |>
  kable_styling(font_size = 12)
Every priced route to London: Brighton vs Leicester (£)
station route_desc dest_name anytime_day_return anytime_return offpeak_day_return offpeak_return season_basis weekly_season
BRIGHTON ANY PERMITTED CITY THAMESLINK 41.9 NA 18.2 33.1 NA 99.2
BRIGHTON ANY PERMITTED LONDON BLKFRIARS 41.9 NA 18.2 33.1 NA 99.2
BRIGHTON ANY PERMITTED LONDON TERMINALS 54.5 NA 30.9 36.4 NA 117.4
BRIGHTON ANY PERMITTED LONDON VICTORIA 51.9 NA 29.6 35.3 NA 108.3
BRIGHTON ANY PERMITTED LONDON ZONES 1-6 59.2 NA 34.3 NA NA 138.3
BRIGHTON NOT UNDERGROUND LONDON ST PANCRS 41.9 NA 18.2 33.1 NA 99.2
BRIGHTON THAMESLINK ONLY LONDON TERMINALS 41.9 NA 18.2 33.1 NA 99.2
BRIGHTON THAMESLINK ONLY LONDON ZONES 1-6 49.4 NA 23.9 NA NA 108.0
LEICESTER ANY PERMITTED LONDON TERMINALS NA 162.5 NA 94.0 233 270.0
LEICESTER ANY PERMITTED LONDON ZONES 1-6 169.5 NA NA NA NA 289.7

The route-level detail shows exactly the effect you noticed: Brighton has an “any permitted” fare (usable to Victoria), a Victoria-specific fare, and a cheaper Thameslink-only fare into Blackfriars / City Thameslink / London Bridge — while Leicester has a single “any permitted” price to St Pancras with no cheap-route alternative, and a season basis (PSS) that still prices the annual at roughly double Brighton’s despite a similar journey time. Competition between routes, not distance, is doing the work.

Housing: closing the triangle

Cheap seasons are worthless if the houses cost a fortune, and vice versa. To close the triangle we bring in the house price per square metre dataset (every Land Registry transaction in England & Wales matched to its EPC floor area), geocoded to postcode via the ONS Postcode Directory. R/build_station_house_prices.R computes, for every station, the median price per square metre of all homes sold within 2 km, in two windows: 2016–18 (consistent with the May 2018 fares and timetable used throughout) and 2022–24. Scottish stations drop out — the dataset covers England & Wales.

Code
hpx <- fread(file.path(dirname(feed_dir), "data", "station_house_prices.csv"))
th <- merge(tt, hpx, by = "crs")[n_1618 >= 50 & dist_km <= 250]

That gives 644 stations with real journey times, London fares, and at least 50 nearby sales.

Do house prices already price the commute?

Urban economics says they should: pay less to get into London and the saving gets capitalised into what you pay for a house near the station. Plotting the housing market against the full annual cost of commuting (fare + time) shows that trade-off operating — and, more usefully, the stations that sit below the curve, where the market hasn’t fully charged for the access.

Code
# trend line and residual bands: ±1σ / ±2σ around the loess fit
lo <- loess(ppm2_1618 ~ gen_cost, data = th)
th[, `:=`(fit = predict(lo, gen_cost))]
th[, resid_ppm2 := ppm2_1618 - fit]
sd_r <- sd(th$resid_ppm2)
th[, z := resid_ppm2 / sd_r]

band <- data.table(gen_cost = seq(min(th$gen_cost), max(th$gen_cost),
                                  length.out = 200))
band[, fit := predict(lo, gen_cost)]
band <- melt(band[, .(gen_cost, `trend` = fit,
                      `+1σ` = fit + sd_r, `-1σ` = fit - sd_r,
                      `+2σ` = fit + 2 * sd_r, `-2σ` = fit - 2 * sd_r)],
             id.vars = "gen_cost", variable.name = "line", value.name = "y")
band[, lt := fifelse(line == "trend", "solid",
                     fifelse(line %in% c("+1σ", "-1σ"), "dashed", "dotted"))]
band_lab <- band[, .SD[which.max(gen_cost)], by = line]

th[, txt := sprintf(paste0("%s<br>£%s/m² (median %s sales)<br>£%s season | ",
                           "%d min | gen. £%s pa<br>vs trend: %+.0f £/m² (%+.1fσ)"),
                    station, comma(round(ppm2_1618)), comma(n_1618),
                    comma(annual_season), time_min, comma(round(gen_cost)),
                    resid_ppm2, z)]
sdh <- highlight_key(th, ~station, group = "Housing vs commute — search stations")
gh <- ggplot(sdh, aes(gen_cost, ppm2_1618, text = txt)) +
  geom_line(data = band, aes(gen_cost, y, group = line, linetype = lt),
            colour = "grey35", linewidth = 0.5, inherit.aes = FALSE) +
  scale_linetype_identity() +
  geom_text(data = band_lab, aes(gen_cost, y, label = line),
            inherit.aes = FALSE, hjust = 0, nudge_x = 150,
            size = 3, colour = "grey30") +
  geom_point(aes(colour = time_min), alpha = 0.7, size = 1.8) +
  geom_point(data = th[abs(z) > 2], aes(gen_cost, ppm2_1618),
             inherit.aes = FALSE, shape = 1, size = 3.4,
             colour = "firebrick", stroke = 0.8) +
  scale_colour_viridis_c(name = "Journey (min)") +
  scale_x_continuous(labels = label_currency(prefix = "£"),
                     expand = expansion(mult = c(0.02, 0.08))) +
  scale_y_continuous(labels = label_currency(prefix = "£")) +
  labs(x = "Generalised annual commuting cost (fare + time)",
       y = "Median £/m², homes within 2 km (2016–18)") +
  theme_minimal()
searchable(gh)

Median £/m² of homes sold within 2 km of the station (2016–18) vs generalised annual commuting cost. Grey line = loess trend; dashed/dotted lines = ±1σ and ±2σ of the residuals around it. Ringed points sit beyond ±2σ. Searchable.

One residual standard deviation here is £716 per m² — on a 90 m² home, a ±1σ move is about £64k. The ringed stations beyond ±2σ are the extremes: above the +2σ line, housing costs far more than London access explains (places sold on schools, sea or setting rather than the commute); below the −2σ line, the housing is much cheaper than its commuting cost predicts.

Code
out <- th[abs(z) > 2][order(-z)]
out[, .(Station = station, `£/m²` = comma(round(ppm2_1618)),
        `Trend £/m²` = comma(round(fit)), `σ` = sprintf("%+.1f", z),
        `Season` = comma(annual_season, prefix = "£"), `Mins` = time_min)] |>
  kable(caption = "Stations more than 2σ from the housing–commute trend") |>
  kable_styling(full_width = FALSE, font_size = 12)
Stations more than 2σ from the housing–commute trend
Station £/m² Trend £/m² σ Season Mins
OXFORD 6,316 3,645 +3.7 £5,096 57
STRAWBERRY HILL 7,244 5,041 +3.1 £1,168 33
TEDDINGTON 7,210 5,112 +2.9 £1,196 30
FULWELL 7,036 4,983 +2.9 £1,196 35
CAMBRIDGE 5,854 3,849 +2.8 £4,540 52
BATH SPA 4,503 2,576 +2.7 £9,916 89
HAMPTON WICK 7,083 5,164 +2.7 £1,196 28
BROCKENHURST 4,795 2,898 +2.7 £6,088 93
TWICKENHAM 7,255 5,410 +2.6 £1,168 19
HARPENDEN 6,026 4,201 +2.6 £4,672 33
ST ALBANS CITY 6,259 4,537 +2.4 £3,712 28
KINGSTON 6,881 5,243 +2.3 £1,196 25
LONDON RD GUILFD 5,710 4,099 +2.3 £3,460 50
WINDSOR & E RIV 5,767 4,177 +2.2 £2,976 51
DORRIDGE 4,158 2,624 +2.1 £8,380 98
KINGHAM 4,348 2,826 +2.1 £7,124 89
COBHAM & STOKE D 6,122 4,645 +2.1 £2,660 34
CLAVERDON 3,974 2,505 +2.1 £9,340 106
GUILDFORD 5,645 4,182 +2.0 £4,460 36
THAMES DITTON 6,563 5,112 +2.0 £1,196 30
RADLETT 5,970 4,528 +2.0 £3,348 32
SHELFORD CAMBS 4,778 3,339 +2.0 £4,224 82
TILBURY TOWN 3,156 4,592 -2.0 £2,376 39
CHATHAM 2,597 4,127 -2.1 £4,124 42
PETERBOROUGH 1,982 3,524 -2.2 £6,540 49
GILLINGHAM KENT 2,500 4,047 -2.2 £4,124 46
WELLINGBOROUGH 2,019 3,607 -2.2 £6,300 47
PITSEA 2,839 4,443 -2.2 £3,004 39
NORTHAMPTON 2,069 3,759 -2.4 £5,604 46
Code
mh <- lm(ppm2_1618 ~ I(gen_cost / 1000), data = th)
grad <- -coef(mh)[[2]]

The gradient is real money: across these stations, each extra £1,000 of annual commuting cost is associated with homes about £133 per m² cheaper (R² = 0.54) — roughly £12k on a 90 m² house. That is the market pricing London access. The residual scatter around it is where bargains and traps live.

The all-in cost of a London working life

To rank places on one number, price a standardised 90 m² home at the local £/m² and add the commute as a lump sum — a perpetual annual cost capitalised at 3.5% (the Green Book discount rate; £1,000/yr forever ≈ £28,600 today). Change either assumption to taste.

Code
home_m2 <- 90; disc <- 0.035
th[, all_in := home_m2 * ppm2_1618 + gen_cost / disc]
setorder(th, all_in)
th[1:20, .(Station = station, `£/m²` = comma(round(ppm2_1618)),
           `90m² home` = comma(round(home_m2 * ppm2_1618 / 1000), prefix = "£", suffix = "k"),
           `Season` = comma(annual_season, prefix = "£"), `Mins` = time_min,
           `All-in` = comma(round(all_in / 1000), prefix = "£", suffix = "k"))] |>
  kable(caption = "Lowest all-in cost: 90 m² home + capitalised commute (2016–18 prices and fares)") |>
  kable_styling(full_width = FALSE, font_size = 12)
Lowest all-in cost: 90 m² home + capitalised commute (2016–18 prices and fares)
Station £/m² 90m² home Season Mins All-in
PITSEA 2,839 £256k £3,004 39 £453k
TILBURY TOWN 3,156 £284k £2,376 39 £464k
BASILDON 3,222 £290k £2,852 34 £469k
DAGENHAM DOCK 3,978 £358k £1,692 22 £470k
CHATHAM 2,597 £234k £4,124 42 £472k
OCKENDON 3,557 £320k £2,376 30 £474k
GOODMAYES 4,184 £377k £1,320 21 £475k
RAINHAM ESSEX 3,869 £348k £1,916 25 £475k
GILLINGHAM KENT 2,500 £225k £4,124 46 £475k
PURFLEET 3,576 £322k £2,376 30 £476k
LAINDON 3,450 £311k £2,852 30 £478k
ROCHESTER 2,794 £251k £4,124 38 £478k
NORTHAMPTON 2,069 £186k £5,604 46 £478k
CHADWELL HEATH 4,047 £364k £1,692 23 £479k
SEVEN KINGS 4,302 £387k £1,320 19 £479k
BELVEDERE 3,836 £345k £1,772 31 £485k
STEVENAGE 3,485 £314k £3,740 23 £487k
GRAYS 3,509 £316k £2,376 36 £487k
DARTFORD 3,708 £334k £2,484 29 £488k
ERITH 3,765 £339k £2,016 33 £491k
Code
mp3 <- th[!is.na(lat)]
mp3[, ai_pct := frank(all_in) / .N * 100]
pal3 <- colorNumeric("RdYlBu", c(0, 100), reverse = TRUE)
leaflet(mp3) |>
  addProviderTiles(providers$CartoDB.Positron) |>
  addCircleMarkers(~lon, ~lat, radius = 5, stroke = FALSE, fillOpacity = 0.85,
                   fillColor = ~pal3(ai_pct),
                   label = ~sprintf("%s — all-in £%sk (£%s/m², £%s season, %d min)",
                                    station, comma(round(all_in / 1000)),
                                    comma(round(ppm2_1618)),
                                    comma(annual_season), time_min)) |>
  addLegend("bottomright", pal = pal3, values = ~ai_pct,
            title = "All-in cost percentile")

All-in cost percentile: 90 m² home at local £/m² plus capitalised generalised commute. Blue = cheap all-in, red = dear.

The housing–commute frontier

The all-in number bakes in assumptions; the frontier doesn’t. A station is efficient here if nowhere has both cheaper houses and a cheaper commute — the shortlist for anyone trading one against the other, whatever their weights.

Code
fh <- th[order(gen_cost, ppm2_1618)]
hfront <- fh[ppm2_1618 == cummin(ppm2_1618)][, .SD[1], by = ppm2_1618]
ggplot(fh, aes(gen_cost, ppm2_1618)) +
  geom_point(alpha = 0.25, colour = "grey50", size = 1.3) +
  geom_step(data = hfront, direction = "hv", colour = "firebrick",
            linewidth = 0.7) +
  geom_point(data = hfront, colour = "firebrick", size = 2.2) +
  geom_text_repel(data = hfront, aes(label = station), size = 2.8,
                  max.overlaps = 40) +
  scale_x_continuous(labels = label_currency(prefix = "£")) +
  scale_y_continuous(labels = label_currency(prefix = "£")) +
  labs(x = "Generalised annual commuting cost (fare + time)",
       y = "Median £/m², homes within 2 km (2016–18)") +
  theme_minimal()

Median £/m² vs generalised annual commute. Labelled stations are Pareto-efficient: nothing is cheaper on both axes.
Code
hfront[, .(Station = station, `£/m²` = comma(round(ppm2_1618)),
           `Season` = comma(annual_season, prefix = "£"),
           `Mins` = time_min, `Km` = round(dist_km),
           `Gen. commute` = comma(round(gen_cost), prefix = "£"))] |>
  kable(caption = "The housing–commute efficient stations") |>
  kable_styling(full_width = FALSE, font_size = 12)
The housing–commute efficient stations
Station £/m² Season Mins Km Gen. commute
HARROW & WLDSTNE 5,549 £1,168 14 17 £2,574
SOUTH CROYDON 5,230 £1,012 17 16 £2,719
SOUTHALL 4,596 £1,320 14 18 £2,726
SEVEN KINGS 4,302 £1,320 19 17 £3,228
GOODMAYES 4,184 £1,320 21 18 £3,429
DAGENHAM DOCK 3,978 £1,692 22 19 £3,902
RAINHAM ESSEX 3,869 £1,916 25 22 £4,427
BELVEDERE 3,836 £1,772 31 19 £4,885
ERITH 3,765 £2,016 33 21 £5,330
OCKENDON 3,557 £2,376 30 29 £5,389
LAINDON 3,450 £2,852 30 39 £5,865
BASILDON 3,222 £2,852 34 41 £6,267
TILBURY TOWN 3,156 £2,376 39 34 £6,293
PITSEA 2,839 £3,004 39 44 £6,921
ROCHESTER 2,794 £4,124 38 46 £7,940
CHATHAM 2,597 £4,124 42 47 £8,342
GILLINGHAM KENT 2,500 £4,124 46 49 £8,744
NORTHAMPTON 2,069 £5,604 46 97 £10,224
WELLINGBOROUGH 2,019 £6,300 47 96 £11,020
PETERBOROUGH 1,982 £6,540 49 119 £11,461
KETTERING 1,927 £7,008 55 107 £12,532
GRANTHAM 1,713 £7,272 70 160 £14,302
RETFORD 1,682 £9,448 86 209 £18,085
WOLVERHAMPTON 1,316 £8,380 98 182 £18,222
STOKE ON TRENT 1,274 £11,592 91 218 £20,731
DONCASTER 1,118 £12,212 97 235 £21,954

Median £/m² vs generalised annual commute. Labelled stations are Pareto-efficient: nothing is cheaper on both axes.

Brighton vs Leicester, settled?

Code
bl <- th[station %in% c("BRIGHTON", "LEICESTER")]
gap_house <- diff(range(home_m2 * bl$ppm2_1618))
gap_gen   <- diff(range(bl$gen_cost)) / disc

The housing data finally adjudicates the opening puzzle. Around Brighton, 2016–18 buyers paid a median £4,917/m² against £1,893/m² around Leicester — about £272k more for a 90 m² home. Leicester’s dearer season plus its slightly longer journey amount to a capitalised commuting penalty of roughly £167k. On these assumptions the extra cost of Brighton’s housing outweighs its famously cheaper ticket: all-in, Leicester comes out ahead — the market has more than priced Brighton’s Thameslink bargain into its postcodes.

Caveats and next steps

  • Journey times are estimated from distance unless data/journey_times.csv is supplied. R/build_journey_times.R builds that file from a real timetable: download one of (a) NWR Historical Timetable Data on the Rail Data Marketplace — includes the May 2018 timetable matching this fares feed; (b) the RDG Timetable – Full Refresh product there (current); or (c) the free ATOC ttis*.zip from the National Rail Data Portal — then the script converts it to GTFS with UK2GTFS and computes the fastest direct AM-peak journey to each London terminal.
  • Fares are May 2018. Swap in a current RJFAFxxx feed from the Marketplace “Fares” product; nothing else needs changing.
  • Advance (train-specific) fares, Oyster/contactless PAYG within the zones, and railcard discounts are out of scope — this is walk-up, adult, standard-class pricing.
  • Only fares priced towards London (or explicitly reversible) are used — contra-peak fares out of London are often cheaper and would bias the panel.
  • Zone-priced stations (the Thameslink belt) are represented by their Travelcard fares — the only walk-up products to central London the feed holds for them. A Travelcard includes zonal Tube/bus travel, so these stations’ fares buy a little more than the terminal-only fares used elsewhere; their “value” is if anything slightly understated. Stations wholly inside the TfL zones (e.g. Mill Hill Broadway, Elstree & Borehamwood) have no station-specific National Rail fare to London at all — their price is the TfL zonal product — and so drop out of the panel.
  • Restriction codes (.RST) — e.g. exactly which trains count as off-peak — are carried through but not decoded; two “off-peak” fares can have different fine print.
  • The same pipeline generalises to any other city: replace the fare-group NLC (1072) with e.g. Birmingham stations’ group and re-run.
  • Housing figures are medians of what actually sold within 2 km in 2016–18 — they reflect the local dwelling mix (a flat-heavy town reads cheaper per m² than a detached suburb at the same underlying land value). The 90 m² home and 3.5% capitalisation rate in the all-in ranking are assumptions to vary, and England & Wales coverage only. The 2022–24 window (ppm2_2224 in data/station_house_prices.csv) is there for a present-day re-run alongside a current fares feed; the UK HPI local-authority series could put both windows on a common price basis.