Precompute where splitting a GB rail journey into several tickets can beat the through fare, so that a journey planner can check for split tickets in real time.
npm run splits -- ../dtd2mysql/data/feeds splits.br --date=2026-09-22
This reads the fares feed (RJFA*.ZIP, the latest refresh plus the change files after it) and the routeing guide
(RJRG*.ZIP) and timetable (RJTT*.ZIP) from the directory and writes every split journey to splits.br. The full
network takes under four minutes on 64 cores and makes a 9 MB file.
| Option | |
|---|---|
--date |
Fares valid on this date. Defaults to today |
--routeing |
The routeing guide feed, when it is not next to the fares |
--timetable |
The timetable feed (RJTT*.ZIP) or its directory, when it is not next to the fares |
--split-penalty |
What each ticket after the first costs in the search, in pence. Defaults to 100 |
--permitted-stations |
The Knowledgebase FareGroupPermittedStations XML, when it is not next to the fares. npm run download-permitted-stations fetches it to data/ from the Rail Data Marketplace bucket, with the AWS credentials in .env |
--with-prices |
Write the total price after each journey, for debugging |
WORKERS |
Worker threads. Defaults to the number of CPUs less two |
For every pair of stations, in each fare category, the cheapest combination of tickets where it is cheaper than the through ticket, or where there is no through ticket in that category. Pairs where the through ticket is cheapest are left out.
Categories. Standard class tickets for one adult without a railcard, grouped so a journey is only ever split into tickets of the same kind:
| Category | Tickets |
|---|---|
ADV-S |
Advance singles |
OFP-S, OFP-R |
Off-peak and super off-peak singles and returns |
ANY-S, ANY-R |
Anytime singles and returns |
Tickets are categorised by their product name. Restriction and validity codes are shared with products that are not tickets for one adult, such as under 16 fares, concessions, PAYG information fares and group tickets.
Fares. Fares are looked up as in docs/Fares Lookup.md: a station's own NLC, its fare group, the hardcoded
additional locations and the London zones; one flow per route for each pair of locations, preferring NLC over cluster
and usage code A over G; and non-derivable fares replacing or suppressing flow fares.
Fare groups. A fare to or from a fare group such as London Terminals is only used for the stations of the group the Knowledgebase fare group permitted stations list for the location at the other end and the fare's route. Where nothing is listed, or the file is not given, every station of the group may use it.
Routes. The tickets of a split are either all any permitted (routes 00000 and 01000, which only excludes
Southsea Hoverport), or a mix of any permitted tickets and
tickets for one other route code, so a split never relies on two different route restrictions at once. A split is
compared with the cheapest through ticket on any route. Each route is searched by carrying on from the any permitted
result, only from the stations its fares make cheaper, and only where that could still make a destination cheaper.
Routeing. A split point must be on a route the National Routeing Guide permits between the origin and destination:
on a local journey to one of the origin's routeing points, on a permitted route between routeing points, or on a local
journey from the destination's. See @gb-transit/routeing-source for how permitted routes are worked out and what is
not modelled.
Split points must also be:
- stations a passenger train makes a public call at, in the timetable over the date
- further from the origin than the split point before, and nearer the destination's routeing point, by the routeing guide's distances. Journeys local to the origin's routeing point only have to move away from the origin.
- not forbidden by a negative easement between the origin and destination. Easements that only apply to other routes, to a train company or ticket type, or on some days or at some times, are not applied.
Each ticket after the first costs the split penalty on top of its fare in the search, so a split has to save at least
that much per extra ticket, and of two splits that save the same the one with fewer tickets wins. Prices written with
--with-prices are without it.
Stations without routeing guide data (trams, buses, ferries and some duplicate codes) are not part of any split.
One file, brotli compressed unless the name ends in .gz. Each section starts with a #CATEGORY ROUTE line, followed
by its journeys as the concatenated CRS codes of the origin, the split points and the destination, sorted and front
coded: the first character is how many stations the line shares with the one before ('0' + n), followed by the rest.
#ANY-S 00000
0NRWDISLST NRW DIS LST
2SMKIPS NRW DIS SMK IPS
#ANY-S 00700
0NRWELYCBG NRW ELY CBG, with any permitted and route 00700 tickets
import { loadSplitFile, planSplits } from "splicketer";
const splits = await loadSplitFile("splits.br");
splits.split("ANY-S", "NRW", "IPS"); // {route: "00000", points: ["DIS", "SMK"]}
planSplits(splits, "ANY-S", callingPoints, (from, to, route) => liveFare(from, to, route));
// {route: "00000", segments: [[["NRW", "DIS"], 550], [["DIS", "LST"], 6000]]}planSplits keeps, for each route, the calling points that are a split point of the cheapest split between two of
the journey's calling points on that route or on any permitted tickets, and drops the rest. It then runs
least-weight-subsequence over each route's candidates with
the fares given, which should be the cheapest ticket valid for that route or any permitted, and returns the cheapest.
planSplitsAsync does the same with fares looked up asynchronously.
Loading the full file takes about two seconds and 420 MB. Finding the candidates for a journey takes a few microseconds.
.github/workflows/splits.yml runs every morning:
- Downloads the fares, timetable and routeing guide feeds from the DTD, the fare group permitted stations from the Rail Data Marketplace bucket, and gb-transit's latest GTFS feed for station names and coordinates.
- Searches the origins in 16 shards in parallel (
--shard=N/16), since the whole network is several CPU hours. - Merges the shards (
npm run merge-splits) and releasessplits.br,splits-meta.jsonandstations.jsonassplits-YYYY-MM-DD. The last 30 releases and the first of each month are kept.
It needs the DTD_USERNAME, DTD_PASSWORD and DTD_HOSTNAME secrets for the feeds, and AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY for the bucket, which is in eu-west-2.
.github/workflows/pages.yml then builds site/ with the latest release copied in and deploys it to GitHub Pages.
A page cannot fetch a release asset itself, as they are served without CORS headers. The site reads the whole split
file in a worker, decompressing the brotli with brotli-dec-wasm, and shows where to split a journey between two
stations in each category on a map.
To work on the site, put a splits.br, splits-meta.json and stations.json in site/public and run
npm run dev --prefix site. npm run stations -- gtfs.zip stations.json writes the stations from a GTFS feed.
npm test # biome lint, tsc and vitest
npm run perf # load time and candidate lookups against splits.br