A QGIS processing plugin for transit accessibility analysis on the Conveyal R5 routing engine — travel-time matrices, cumulative-opportunity accessibility and isochrones over a departure-time window, computed inside QGIS with no R, no conda and no Docker.
Status: 0.3.0, experimental. All fifteen algorithms work; the travel-time matrix and accessibility are verified end-to-end (accessibility reproduces r5r's Gdańsk output exactly —
docs/notes/validation-gdansk.md). New in 0.3.0, each verified on a real Łódź network: network scenarios (draw a new line, remove or re-time routes — R5 applies them in memory) with Compare scenarios, a GTFS pre-flight check, 2SFCA competitive accessibility and a population-weighted equity summary — seedocs/prd/PR_easy-R5_v03.md. The flag staysexperimentaluntil a clean-install run of the full pipeline is signed off. SeeKNOWN_ISSUES.md.
Sibling project: easy-OTP, the same idea on OpenTripPlanner 1.5.
How it works: How QGIS talks to R5 — Easy-R5 runs the R5 jar as a child process through one small Java file, not through r5r (needs R) or r5py (16 pip packages).
Current flagship result: tools/realtime_delay_lodz/ —
accessibility computed on Łódź's static GTFS schedule compared against the realized P50
schedule (the median of what vehicles actually did), same day, same network otherwise. The
board above shows the net change in reachable schools, pharmacies, universities and shopping
malls within a 30-minute trip once those real-world delays are applied — red hexagons lose
access, blue gain it. The two zoomed insets contrast the dense city centre, where overlapping
routes absorb a late vehicle, against a ring roughly 2 km further out, where access depends on
one specific transfer — miss it, and the wait is a full headway, not a few extra seconds.
| easy-OTP | Easy-R5 | |
|---|---|---|
| Engine | OpenTripPlanner 1.5 (Java 8) | Conveyal R5 (Java 21) |
| Best at | per-minute travel-time surfaces, detailed itineraries, live GTFS-RT | one-to-many / many-to-many travel times over a departure-time window, cumulative accessibility |
| Realtime | yes — records GTFS-RT and reconstructs realized feeds | no — R5 cannot read GTFS-RT; it consumes the realized static feeds easy-OTP produces |
The two are designed to share data: the same OSM extracts, GTFS feeds and hex grids work in both.
Processing toolbox → Easy-R5:
| Group | Algorithm | Does |
|---|---|---|
| Setup | Download R5 engine and Java 21 | fetches Temurin 21 + r5-v7.6-all.jar (SHA-256 pinned), compiles the runner. No admin rights. |
| Setup | Download realized GTFS | pick a city + date + variant (realized P50/P85 or scheduled), download from GISBoost's gtfs-dashboard index into a folder ready for Build R5 network. |
| Setup | Build R5 network | one .osm.pbf + a folder of GTFS .zip → cached network.dat + a network.json summary with a per-date active-trip count. |
| Diagnostics | Test R5 setup | checks the JDK, jar and runner independently. |
| Diagnostics | Check transit data (GTFS) | before building: calendar span and active trips per day, whether your DATE has service, route types R5 cannot read, broken references between files, stops outside the study area, a realized feed sharing a folder with its static feed. HTML report + routes CSV (the names to use in Build scenario). |
| Analysis | Run travel time matrix | N origins × M destinations, percentiles over a departure window, batched processes, sampled time estimate, hard dead-date gate + post-run walk-only detector. Long CSV out. TRANSIT_SUBMODES narrows which transit modes R5 routes over (e.g. TRAM only, or TRAM, BUS) — blank means all. |
| Analysis | Run accessibility | opportunities reachable per origin / cutoff / percentile (STEP / LOGISTIC / EXPONENTIAL decay), summed in Python from the matrix. Long CSV + an ORIGINS copy with acc_<opp>_p<pct>_c<cutoff> fields. Same TRANSIT_SUBMODES narrowing as the matrix — run once for TRAM and once for BUS to compare modal accessibility. |
| Analysis | Run service minutes | for each origin-destination pair, how many of the departure window's minutes (120 by default) reach the destination within each cutoff — from R5's per-minute travel-time histogram, reduced in Java. Long CSV (svc_min_c<cutoff> per cutoff, values 0-120). Not the same number as easy-OTP's service-time classification (otp_mean/st_class) — different mechanism, different reference window; see docs/prd/PR_easy-R5_v02_service-minutes.md. |
| Analysis | Run competitive accessibility (2SFCA) | two-step floating catchment area: capacity (doctors, school places) shared by the population that can reach it within a catchment — e.g. doctors per 1000 residents. STEP = classic 2SFCA, LOGISTIC / EXPONENTIAL = E2SFCA-style weighting. |
| Analysis | Summarize accessibility equity | population-weighted share of residents at or above a threshold, residents with none, mean, p10–p90, Gini — overall and per district. Table + HTML report with one plain-language sentence per field. |
| Analysis | Generate isochrones | cumulative travel-time polygons, one per (origin, cutoff): a destination grid → one-origin matrix → TIN raster → gdal:contour_polygon per cutoff (the approach r5r/r5py/Conveyal all use — R5 has no isochrone output). Unreachable pockets stay as holes. |
| Analysis | Prepare population layer | joins a GUS NSP 2021 sheet to census-tract geometry. |
| Analysis | Population overlay | area-weighted population onto a hex grid (fractional, not rounded). |
| Scenarios | Build scenario | a scenario file: new lines drawn as QGIS line features (each vertex a stop; speed, headway, service hours), existing routes removed, sped up / slowed down, or given a new headway. Feed it to the SCENARIO parameter (Advanced) of every matrix, accessibility, service-minutes, isochrone and 2SFCA run — nothing is rebuilt. |
| Scenarios | Compare scenarios | two result layers of the same places, one field → value_a, value_b, diff, pct_change, status (better / worse / same), styled. Refuses to diff runs with different percentile / decay / window. |
Isochrones are contoured in QGIS — R5 has no isochrone output (neither does r5r's or
r5py's engine call; both grid-and-contour, like this). There is no hex-grid
algorithm: use stock native:creategrid (recipe below).
See docs/notes/product-scope.md and
docs/notes/r5-vs-otp.md for what is deliberately not here
(itineraries, GTFS-RT; scenarios shipped in 0.3.0, the service-minutes metric in 0.2.2).
- Install — Easy-R5 is not yet in the QGIS plugin repository. Either copy the
easy_r5/folder into your QGIS profile'spython/plugins/, or build a ZIP from a checkout (py tools/build_plugin_zip.py→builds/easy_r5-<version>.zip) and use Plugins → Manage and Install → Install from ZIP. Then enable it. - Download the engine — run Setup → Download R5 engine and Java 21, pick a target folder in your user profile. One-time, ~200 MB (Temurin 21 JDK + the R5 jar).
- Get data — you supply the OSM extract (
.osm.pbffrom Geofabrik or BBBike) and the GTFS feed(s) (.zip) for your study area. For a realized feed (what actually ran on a given day, P50/P85) or that day's scheduled feed, see Archival / realized GTFS below. - Check the feed — Diagnostics → Check transit data (GTFS) with the date you plan to analyse. Zero errors means the network build and the date gate will not surprise you.
- Build a network — Setup → Build R5 network: the
.osm.pbfand a folder holding your GTFS.zip(s). Cached by content hash + R5 version, so re-runs are instant. - Analyse — Run travel time matrix, Run accessibility or Run service minutes: the
network from step 4, an origins point layer, a destinations point layer, a
DATEthe feed actually serves (the run is blocked otherwise), a departure time and window. Output layers are styled automatically (except Run service minutes, which has no single field to style a gradient by — see its PRD). - What if? — Scenarios → Build scenario (draw a line, list routes to remove), then re-run
step 6 with the file in
SCENARIO(Advanced) and Compare scenarios against the baseline run. Summarize accessibility equity turns either run into "X% of residents reach …". Step-by-step instructions for each of these are in Guides below.
The Gdańsk reference data — 1389 origins, 956 destinations, the r5r ground-truth output — is in
tools/accessibility_cities/gdansk/; the exact-match
comparison is in docs/notes/validation-gdansk.md.
The table above says what each algorithm is for. This section explains, for the algorithms added
in 0.2.2 and 0.3.0, what question each one answers, how it works inside, how to run it, and what
to watch out for. All of them live in the Processing toolbox under Easy-R5. Every parameter
named in CAPITALS below is the parameter's id; in the dialog it has a readable label, and the
ones marked Advanced are under the dialog's Advanced parameters fold.
A typical study chains them like this:
Check transit data ─► Build R5 network ─► Run accessibility / Run competitive accessibility
│ (baseline) │ (with SCENARIO = file)
Build scenario ──────┘ │
└──► Compare scenarios ◄┘
│
Summarize accessibility equity (either run, or both)
Worked example. All of this run end to end on the real Łódź network, with the QGIS project, the layers and the numbers:
tools/demo_v03_lodz/. Headline results — 97.0% of residents reach at least one school within 30 minutes, one scenario tram line brings 6 280 more people into that group (3.0% → 2.1% with no access), and 2SFCA shows that only 44% of them have as many schools per capita as the city average (82% in the centre, 35% outside). It also documents a 2SFCA trap worth knowing: destinations outside the origins' area divide their capacity by almost nobody and explode the values — the algorithm now warns about it.
Question it answers: "Can I trust this GTFS for an analysis on this date — before I spend minutes building a network?"
Why it exists. When a GTFS feed has no trips on the chosen date, R5 does not fail — it quietly returns walking-only travel times, and the map looks plausible. That exact mistake once reached a published result. Easy-R5 already blocks such runs at analysis time; this algorithm catches the problem earlier, while you can still pick another feed or date.
How it works. Pure Python reading the .zip files; no Java, no network build. It reads the
calendar the same way the analysis-time date check does (weekday patterns in calendar.txt,
additions and removals in calendar_dates.txt) and counts trips actually running on each day.
It then checks the files against each other and against what R5 7.6 can read.
How to use it.
- Open Check transit data (GTFS).
GTFS— pick one GTFS.zip. WithWHOLE_FOLDERticked (default) every.zipin the same folder is checked together, because Build R5 network reads the whole folder. Untick it if the zip sits among unrelated downloads.DATE— the date you intend to analyse,yyyy-MM-dd. Optional, but it is the most useful check.EXTENT— optional: your study area (e.g. Calculate from layer → your OSM or boundary layer).OUTPUT_REPORT— where to save the HTML report. Optionally alsoOUTPUT_SERVICE_DAYS(a CSV of trips per date) andOUTPUT_ROUTES(a CSV of every route — you will want this for scenarios).- Run, then open the HTML report in a browser.
Reading the report. Findings are sorted ERROR → WARN → INFO, followed by a bar chart of trips per day.
| Level | Examples | What to do |
|---|---|---|
| ERROR | no trips on DATE (the report lists the 3 nearest served days); a required file missing; a route_type R5 7.6 cannot read (it supports 0–7, 11, 12, 100–1499); a realized P50/P85 feed and its scheduled feed in one folder; two feeds with the same feed id; no stop inside EXTENT |
fix before building — the build or the analysis would fail or silently mislead |
| WARN | trips pointing at routes/stops that do not exist; stops at 0,0; part of the stops outside EXTENT; days inside the calendar with no service |
usually survivable, but read them |
| INFO | calendar span, trips per day (min/median/max), routes per type, stop bounding box | context |
ERRORS and WARNINGS are also returned as numbers. FAIL_ON_ERROR (Advanced) makes the
algorithm itself fail when there are errors — useful inside a model.
Question it answers: "What happens to travel times / accessibility if we add this tram line, close route 86, slow the buses down, or run route 14 every 5 minutes?"
How it works. A scenario is a small .json file listing modifications to the transit
network. You never edit GTFS and never rebuild the network: when an analysis algorithm gets a
scenario file, the Java runner loads the normal network.dat, asks R5 to apply the modifications
to an in-memory copy (about 1–2 s), recomputes walking transfers for any new stops, and routes
on that copy. The saved network stays untouched, so the baseline is always one run away.
Four kinds of modification are available from the dialog:
| Modification | What R5 does |
|---|---|
| New line (from a line layer) | adds a new route: a stop at each vertex, runs both ways (default) every HEADWAY_MINUTES between SERVICE_START and SERVICE_END, every day |
| Remove routes | deletes every trip of the listed routes |
| Change speed | scales the running time of the listed routes (SPEED_SCALE 0.8 = 20% slower, 1.25 = 25% faster) |
| New headway | replaces the timetable of the listed routes, between HEADWAY_START and HEADWAY_END, with a regular service every NEW_HEADWAY_MINUTES; trips outside that window are kept. Each direction keeps its busiest variant; short-turn and depot variants inside the window are dropped rather than each getting the full frequency |
Routes are named the way passengers know them (86, Z2) — or by GTFS route_id, or
feed:route_id when several feeds share a name. The exact list is in Check transit data's
OUTPUT_ROUTES CSV. A name that matches nothing stops the analysis with a clear error; a name
that matches several routes produces a warning listing them.
1. Draw the line in QGIS. The new line is an ordinary QGIS line layer — you draw it by hand. The one rule: every vertex you click is a stop, in the order you click them.
- Layer → Create Layer → New Temporary Scratch Layer… (or New GeoPackage Layer… if you want to keep it after closing QGIS).
- Geometry type LineString, any CRS (it is reprojected automatically). Optionally add a text
field
name. OK. - Select the layer, Toggle Editing (pencil icon), then Add Line Feature (Ctrl+.).
- Left-click once at each stop location, first stop to last — typically at intersections or where a stop would really be. Do not add vertices for bends of the track: each vertex becomes a stop.
- Right-click to finish the line, type a name if you added the field, OK.
- Repeat for more lines if you want, then Save Layer Edits and toggle editing off.
The drawn path between stops is not followed. Only the stop positions matter: the travel time
between two consecutive stops is their straight-line distance divided by SPEED_KMH, plus
DWELL_SECONDS at each stop. SPEED_KMH is therefore a speed over straight-line distance —
roughly 10–20% below the timetable speed along the real track, because the track is longer than
the straight line:
| Mode | SPEED_KMH |
|---|---|
| Tram in a city | 18–22 (the default is 20) |
| City bus in traffic | 15–20 |
| Bus on its own lane / BRT | 20–25 |
| Metro | 25–35 |
| Regional rail | 45–60 |
The log prints each line's end-to-end travel time — compare it with a similar existing line before trusting the result. New stops are linked to the nearest street, and R5 recomputes walking transfers between them and the existing stops, so passengers can change between the new line and the existing network.
2. Build the scenario file. Open Build scenario:
| Parameter | Example |
|---|---|
NEW_LINES |
your drawn layer |
LINE_NAME_FIELD |
name (optional) |
NEW_LINE_MODE |
TRAM |
SPEED_KMH |
20 |
HEADWAY_MINUTES |
7.5 |
SERVICE_START / SERVICE_END |
05:00 / 23:00 — must cover your analysis departure time plus its window |
OUTPUT_SCENARIO |
e.g. …/scenarios/new_tram.json |
Leave REMOVE_ROUTES etc. empty, or combine them in the same file (for example, the new tram
plus removing the bus it replaces). The log prints each new line's stop count and end-to-end time
— check that the time is plausible.
3. Run the baseline. Run Run accessibility (or any matrix-based algorithm) as usual, with
SCENARIO empty. Save the output layer to a file, e.g. acc_baseline.gpkg.
4. Run the scenario. Run the same algorithm with exactly the same parameters, and in
Advanced parameters set SCENARIO to new_tram.json. Save to acc_new_tram.gpkg. The output
layer gets a scenario field (new_tram.json:<checksum>; the baseline says baseline), so the
two layers can never be confused later.
5. Compare. Compare scenarios with LAYER_A = baseline, LAYER_B = scenario (see below).
From the worked example: a drawn tram line, 7 stops, every 6 min. Six hexagons gain, none lose; the one at the eastern end goes from 0 to 18 schools within 30 min.
The same SCENARIO parameter works in Run travel time matrix, Run accessibility, Run service
minutes, Generate isochrones and Run competitive accessibility.
Things to know.
- A scenario that removes routes can leave some trips walking-only; the analysis then warns instead of failing. For any other scenario, "not one pair uses transit" is still a hard error, because it almost always means a wrong date or a feed that does not match the map.
- Scenario lines run as a regular service ("every N minutes"), so R5 randomises where in the headway a passenger arrives — see Speed of scenario runs below.
- Advanced users can hand-write the JSON with any of R5's own modification types
(
add-trips,remove-trips,adjust-speed,adjust-frequency,remove-stops,reroute, …).
Question it answers: "Where did it get better or worse, and by how much?"
How it works. Joins two result layers by an id field (not by location), takes one numeric
field from each and writes a copy of layer A with value_a, value_b, diff (= B − A),
pct_change and status: better, worse, same, only_a, only_b. The output is coloured
by status.
It refuses to compare runs whose method differs — percentile, decay, departure time, window, modes, catchment, maximum trip time, maximum walk, walking speed, maximum rides, Monte Carlo draws — because such a difference mixes a method change into the result. It reports, but accepts, differences that are the point of a comparison: date, network, scenario, transit sub-modes.
How to use it.
LAYER_A— before / baseline;LAYER_B— after / scenario.JOIN_FIELD— the id field (the same origin id you used in the analysis).JOIN_FIELD_Bonly if it is named differently in B.FIELD— the value to compare, e.g.acc_jobs_p50_c30;FIELD_Bonly if named differently.HIGHER_IS_BETTER— on for accessibility, service minutes and 2SFCA (an empty value counts as 0). Off for travel times: lower is better, and an empty value means "unreachable", so a lost connection shows asworse, never as a gain.- Run. The log summarises better / worse / same counts and the mean change.
ALLOW_METHOD_MISMATCH (Advanced) overrides the method check — only when you really mean to
compare, say, P50 with P85.
Comparing on a hex grid. Compare scenarios keeps layer A's geometry, whatever it is, so the result can be points or polygons. Accessibility runs need point origins, so a hex-grid study has two equally good routes to a filled hex map:
- Compare first, then join. Run the comparison on the two accessibility outputs (hex centroids),
then Processing →
native:joinattributestable: input = your hex polygons, field = the hex id, input 2 = the comparison layer, field 2 = the same id. Style the joined hexes bydifforstatus. - Join first, then compare. Join each accessibility output to a copy of the hex polygons, then
compare the two polygon layers directly — the output is already hexagonal and coloured by
status.
Either way the hex id must be a real field on the origins (use it as ORIGIN_ID_FIELD in the
analysis), not the QGIS feature id.
It is also the tool for timetable changes and scheduled vs realized comparisons: run the same analysis on two networks and compare.
Question it answers: "How many doctors (school places, beds …) are there per 1000 residents, counting that everyone who can reach a clinic competes for it?"
Run accessibility counts what you can reach — a clinic with 5 doctors counts as 5 for everyone within 30 minutes, however many people that is. 2SFCA (two-step floating catchment area, Luo & Wang 2003) shares the supply among the people who can reach it.
How it works. One travel-time matrix from demand points (origins, with population) to supply points (destinations, with capacity), then two steps in Python:
- For each destination:
ratio = capacity ÷ population that reaches it within CATCHMENT_MINUTES. - For each origin: sum the ratios of every destination it reaches within the catchment, then
multiply by
PER_POPULATION.
With DECAY = STEP this is classic 2SFCA. LOGISTIC / EXPONENTIAL give nearer destinations
more weight (an E2SFCA-style variant); the weight still drops to 0 at the catchment, so results
are sensitive to CATCHMENT_MINUTES. The log checks the method's own identity: all supply that
anyone can reach is distributed — no more, no less.
How to use it.
ORIGINS— population points, e.g. hex-grid centroids with a population field from Population overlay. They must cover everyone who competes for the destinations, not only the area you want to map, or the supply looks less crowded than it is.POPULATION_FIELD— the population field on the origins.DESTINATIONS+CAPACITY_FIELD— the facilities and their capacity (doctors, places, beds).CATCHMENT_MINUTES— e.g. 30.PERCENTILES— one value (50 by default).PER_POPULATION— 1000 gives "per 1000 residents".- The date, time, mode and other travel parameters are the same as in Run accessibility;
SCENARIOworks too. - Outputs:
OUTPUT_LAYER(origins +fca), optionalOUTPUT_SUPPLY_LAYER(facilities +supply_ratioanddemand_in_catchment— which facilities are overloaded),OUTPUT_CSV.
Same city, same schools, same 30 minutes as a cumulative accessibility map — but read differently: dark hexagons can reach schools, they just compete with everyone else for them (worked example).
1. Prepare the demand side (origins). You need points that carry population.
- Build a hex grid over the study area and take its centroids (see Hex grid below), keeping a
unique
hexidfield. - Get population onto them: Prepare population layer (a GUS NSP sheet joined to census-tract geometry) → Population overlay (area-weighted population onto the hex grid) → centroids, or any population field you already have.
- Make sure the grid covers everyone who competes for the facilities, not only the district you want to map — otherwise a clinic looks less crowded than it is. A ring of roughly the catchment's width beyond your area of interest is the usual compromise.
2. Prepare the supply side (destinations). A point layer of facilities with a numeric capacity
field: doctors, school places, hospital beds, counter positions. If you only have locations, add a
field with 1 in each — the result is then "facilities per 1000 residents", which is still
competitive, just coarser.
3. Run it. Run competitive accessibility (2SFCA):
| Parameter | Value |
|---|---|
NETWORK |
your network.dat |
ORIGINS / ORIGIN_ID_FIELD |
hex centroids / hexid |
POPULATION_FIELD |
e.g. pop_total |
DESTINATIONS / DEST_ID_FIELD |
clinics / their id |
CAPACITY_FIELD |
e.g. doctors |
DATE, DEPARTURE_TIME, TIME_WINDOW |
a served weekday, e.g. 2026-08-24, 07:00, 120 |
CATCHMENT_MINUTES |
30 |
DECAY |
STEP (classic 2SFCA) |
PERCENTILES |
50 — exactly one value |
PER_POPULATION |
1000 |
OUTPUT_LAYER / OUTPUT_SUPPLY_LAYER / OUTPUT_CSV |
files, not temporary layers, if you want to compare later |
4. Read the result. The origins layer gets fca: capacity per 1000 residents available to the
people living there, given the competition. A hex with fca = 1.6 has 1.6 doctors per 1000
residents within reach; the national or regional average is the natural yardstick. The log prints
a check line — the supply distributed to origins equals the supply anyone can reach — and warns
about facilities nobody reaches. The optional supply layer shows, per facility, supply_ratio
(capacity per person) and demand_in_catchment (how many people can reach it): that is where
"this clinic serves 40 000 people" comes from.
5. Take it further.
- Add
SCENARIOto ask "does the new tram line change who can reach the hospital?". - Feed the output to Summarize accessibility equity to get "X% of residents have fewer than 1 doctor per 1000 within 30 minutes".
- Compare two runs (
fcafield) with Compare scenarios.
Pitfalls.
- Destinations outside the origins' area blow the numbers up. A facility that only a nearly empty edge origin can reach divides its capacity by that handful of residents, so its ratio explodes and every origin reaching it inherits an absurd value (in the worked example: 4902 schools per 1000 residents, from schools in a neighbouring town). The algorithm warns when a facility has less than one resident in its whole catchment; the fix is to extend the origins beyond your study area, or to clip the destinations to it.
- Population is only as good as its source. Population overlay is areal interpolation: it
spreads a census precinct's residents evenly over its area, so hexes made of fields or forest get
phantom residents, and near-empty hexes are exactly where the 2SFCA blow-up above starts. For a
per-hex reading, redistribute onto building footprints instead — see
docs/notes/population-on-hex-areal-vs-dasymetric.md. - The result is on a different scale from Run accessibility — never compare the two numbers directly, and always say the catchment and percentile next to the number.
- Changing
CATCHMENT_MINUTESchanges every value; it is a modelling choice, not a detail. - A capacity field with zeros is fine; empty/negative counts as 0.
Question it answers: "What share of residents can reach at least one hospital within 30 minutes — and is access spread evenly or concentrated?"
How it works. Takes any layer that has a population field and numeric accessibility fields (typically the output of Run accessibility or Run competitive accessibility on a population grid — those outputs keep the origin layer's fields). Every statistic is weighted by population, so it speaks about people, not hexagons:
| Column | Meaning |
|---|---|
population |
residents counted |
pop_at_least, share_at_least |
residents with a value ≥ THRESHOLD |
pop_zero, share_zero |
residents with no access at all |
mean |
population-weighted mean |
p10 … p90 |
the value below which 10% … 90% of residents fall |
gini |
inequality of access, 0 = everyone equal, towards 1 = concentrated in few places |
An empty accessibility value counts as 0 (no access); a feature with empty or negative population is skipped and counted in the log.
How to use it.
INPUT— the accessibility layer.POPULATION_FIELD— e.g. the field from Population overlay.ACCESSIBILITY_FIELDS— one or more, e.g.acc_hospitals_p50_c30.THRESHOLD—1answers "reaches at least one";500answers "reaches at least 500 jobs".GROUP_FIELD— optional, e.g. a district name joined to the grid: adds one row per district.- Outputs:
OUTPUT_TABLE(a table layer, one row per group × field) andOUTPUT_REPORT(HTML with a plain-language sentence per field, e.g. "ALL: 52.6% of residents (15 000 of 28 500) have acc_jobs_p50_c30 ≥ 500; 0.0% have none.", and the run method recorded in the layer — percentile, date, scenario …).
1. Get accessibility and population onto the same features. The easiest path: run
Run accessibility (or Run competitive accessibility) with ORIGINS = the population grid
centroids, ORIGIN_ID_FIELD = the hex id, and the population field already on that layer — the
output copies every origin attribute, so it carries both the population and acc_* fields.
If your population lives in a different layer, join it first with native:joinattributestable.
2. Run the summary. Summarize accessibility equity:
| Parameter | Value |
|---|---|
INPUT |
the accessibility output layer |
POPULATION_FIELD |
pop_total |
ACCESSIBILITY_FIELDS |
e.g. acc_hospitals_p50_c30 (pick several at once if you want) |
THRESHOLD |
1 — "at least one hospital". Use 500 for "at least 500 jobs", 1.0 for "at least 1 doctor per 1000" on a 2SFCA layer |
GROUP_FIELD |
optional — a district name, a rural/urban flag, an income class |
OUTPUT_TABLE, OUTPUT_REPORT |
the table and the HTML report |
3. Read the report. The first line is the sentence you can quote: "ALL: 52.6% of residents (15 000 of 28 500) have acc_jobs_p50_c30 ≥ 500; 0.0% have none." Then the table:
share_at_least— the headline number, weighted by people.share_zero— the group that has nothing at all, usually the politically relevant one.p10— what the worst-served tenth of residents actually gets;p50the median resident.gini— how unevenly access is spread: 0 means everyone has the same, higher means it is concentrated in a few places. Useful between comparable runs (before vs after, city vs city), not as an absolute grade.- With
GROUP_FIELD, one row per district plus theALLrow: that is the "which district is worst off" table.
4. Use it in a comparison. Run the same summary on the baseline and on a scenario and quote the difference in people: "the new tram line brings 12 300 more residents within 30 minutes of a hospital, and the share with no access falls from 9% to 4%". That is the sentence a council reads; the map is the evidence next to it.
Pitfalls.
- An empty accessibility value counts as 0 — right for accessibility, wrong for a travel-time field. Do not point this algorithm at travel times.
- Features with empty or negative population are skipped; the count appears as a warning.
giniis left empty for a field that contains negative values (adifffield, for instance), because the coefficient is not defined there.
Question it answers: "Not just how long the trip takes, but how often the destination is reachable in time" — a trip with a 25-minute median can be every 8 minutes or once an hour.
How it works. R5 already routes every minute of the departure window. This algorithm keeps
R5's per-minute distribution and counts, for each origin–destination pair and each cutoff, how
many departure minutes arrive within the cutoff (svc_min_c30 = 90 means: leaving at 90 of the
120 minutes gets you there within 30 minutes).
How to use it. Like Run travel time matrix, but with CUTOFFS (e.g. 15,30,45,60) instead
of percentiles. Cutoffs and MAX_TRIP_DURATION must stay below 120 minutes (R5 records this
distribution over a fixed 120-minute range). Output: a CSV with from_id, to_id, svc_min_c<cutoff>…,
values 0–120, and optional OD lines.
This is not the same number as easy-OTP's service-time classification — a different method and reference window. Do not compare the two.
The problem they solve. Easy-R5 never routes a single departure: it routes every minute of
the departure window (120 by default) and reports percentiles over those minutes. For a route with
a real timetable that is enough — departure 07:13 either catches the 07:15 bus or it does not.
But some routes are published as "every 10 minutes" with no exact times: GTFS frequencies.txt
(Warsaw, for example), every line you add with Build scenario, and every route you give a new
headway. For those, "leaving at 07:13" has no defined answer — the next vehicle could come in 10
seconds or in 10 minutes.
What R5 does. For each departure minute it draws several random timetables consistent with the
headway, routes each, and treats every draw as one observation. MONTE_CARLO_DRAWS (Advanced,
default 5 per minute, the same default as r5r) says how many. So a 120-minute window with
frequency routes is 600 routings per origin instead of 120.
Where it shows up. The parameter sits on every matrix-based algorithm (travel time matrix,
accessibility, service minutes, isochrones, 2SFCA) because they all share the same run, but it
only has an effect when the network actually contains frequency routes — that is, when you use
a scenario line or a new headway, or your GTFS has frequencies.txt. On a plain scheduled feed R5
does one pass per minute whatever you set.
More draws mean a more stable answer: with too few, two identical runs can disagree by a minute or two purely by luck, and a comparison would show "changes" that are noise. The defaults are chosen so that this does not happen.
The 0.3.0 fix. Easy-R5 used to pass this number to R5 as-is, but R5 reads it as a total for the whole window — so the default gave about one sample per minute, not five. Results for frequency routes were noisy and two identical runs could differ, which would make Compare scenarios report changes that are not there. 0.3.0 passes it correctly, and identical runs now produce identical results.
What it costs.
- Networks without frequency routes (most Polish scheduled GTFS, with no scenario): no change at all — R5 does one pass per minute regardless of this setting.
- With frequency routes: slower, in proportion to how much of the network runs by frequency. Measured on Łódź with one drawn tram line and a 120-minute window: routing took ~1.4× as long as the same network without it. A network where most routes are frequency-based can approach the theoretical 5×.
- For a quick first look, set
MONTE_CARLO_DRAWSto1— as fast as before, but noisier. Use the default for anything you publish or compare.
Setup → Download realized GTFS, or Plugins → Easy-R5 → Download transit recordings…
for a pick-from-a-list dialog, fetches a feed from
GISBoost's gtfs-dashboard — the index of
recordings produced by the easy-GTFS-RT
pipeline for ~25 cities on specific days. Pick a city, a day, and a variant:
- Realized P50 / P85 — the timetable rewritten to match what vehicles actually did that day (median, or the conservative 85th percentile). This is the only way realtime information enters Easy-R5; R5 does not read GTFS-RT.
- Scheduled — the static GTFS as published for that day.
It downloads into …/transit-recordings/<city>/<date>/<variant>/, ready to hand to
Build R5 network. Realized and scheduled feeds share trip / stop ids, so each variant gets
its own folder. This is not a general GTFS source — for feeds outside GISBoost's
recordings, download from the operator or Mobility Database.
No checksum is published for these assets, so the download is CRC-checked and sniffed for the
GTFS files, nothing stronger.
Easy-R5 ships no hex-grid algorithm. To reproduce gdansk_hex_origins.csv's layout:
- Processing →
native:creategrid—TYPE = Hexagon,HSPACING = VSPACING = 500(metres),GRID EXTENT= your study area,GRID CRS= a metric CRS (e.g. EPSG:2180). native:extractbylocation— keep only hexes that are within / intersect the study-area boundary.native:centroids— the origin points; add anidfield with the Field Calculator (@row_numberor a stable code) and exportid,lon,latafter reprojecting to EPSG:4326.
(See tools/accessibility_cities/HOWTO_MANUAL.md step 4.)
Kept as a dogfooding example, not the flagship result: four Run accessibility passes on
one network — walk / tram+walk / bus+walk / full network, varying only TRANSIT_SUBMODES —
asked how much of Łódź depends on its trams, and it worked end-to-end (27.5% of the city's
30-minute reach disappears without the tram, 21.7% without the bus, 13.6% only via the
tram↔bus transfer; real R5 7.6). We moved away from leading with this once literature review
showed "counterfactual mode removal" is a fairly standard method by 2025-2026 standards —
current direction is tools/realtime_delay_lodz/ (real
GTFS-RT delays vs. service reachability). This one stays as a proof that TRANSIT_SUBMODES
dogfoods correctly, and a candidate to revisit later.
How we measured it → ·
reproduce it →
Easy-R5 runs the R5 engine as a child process, not as an in-process library — which is what r5r and r5py do, and both need dependencies a stock QGIS cannot install (R, or 16 pip packages). Python builds a job and reads the result; the child process does the routing and nothing else:
┌─ QGIS · Python / PyQGIS
│
│ a Processing algorithm collects the parameters
│ core/runner.py builds job.json, spawns the child process,
│ reads its stdout, kills it on cancel
│ matrix.py / accessibility.py / isochrones (contoured in QGIS)
│ → styled QGIS layer (+ r5_version, run_date, … fields)
│
└───────────────┐ job.json ↓ stdout: PROGRESS / DONE / ERROR + CSV ↑
│ a pipe + temp files — no JVM inside QGIS
┌───────────────┘
│ child process · JVM (Temurin 21)
│
│ EasyR5Runner.java one .java file, the only Java we maintain:
│ reads job.json → builds a RegionalTask
│ loops over origins → com.conveyal.r5.TravelTimeComputer
│ writes matrix_000.csv, streams progress lines
│ r5-v7.6-all.jar the official Conveyal build, unmodified
│
└─ separate PID · its own -Xmx heap · an R5 OOM ≠ a QGIS crash
Java does routing only. Grids, contouring, zonal statistics,
classification, styling and reports are all Python / QGIS.
Rough sketch — the full reasoning (why a subprocess, why one .java file, why
not r5r or r5py) is in
How QGIS talks to R5.
| Path | What it is |
|---|---|
easy_r5/ |
the QGIS plugin |
easy_r5/java/EasyR5Runner.java |
the one Java source file that drives R5 (build, matrix) |
CONTEXT.md |
glossary — the words this project uses |
docs/adr/ |
architecture decisions |
docs/notes/ |
engine primer, binding comparison, scope, migration plan, open questions |
tools/ |
standalone R5 research tooling — accessibility and isochrone studies for 7 Polish cities, migrated from easy-OTP (ADR-0003). Gdańsk is the plugin's reference dataset. |
| Requirement | Version | How to get it |
|---|---|---|
| QGIS | 3.22 LTR or newer (developed on 3.40) | qgis.org — the plugin uses the bundled Python and GDAL |
| Java | 21 (Temurin) | Download R5 engine and Java 21 fetches it |
| R5 | pinned r5-v7.6-all.jar — see ADR-0002 |
same algorithm, SHA-256 verified |
| OSM extract | any .osm.pbf covering the study area |
you supply it (Geofabrik, BBBike) |
| GTFS feed(s) | any valid feed | you supply it (the operator, transitfeeds, MobilityData) |
openpyxl (optional) |
3.1.5 | only Prepare population layer / Population overlay need it. The plugin fetches the pure-Python wheel from PyPI (SHA-256 verified, no pip) on first load; if that fails it prints a one-line manual-install hint. Every other algorithm works without it. |
GPL-3.0-or-later. R5 itself is MIT (© Conveyal LLC) and is downloaded, not vendored.
R5 is developed by Conveyal. This plugin is not affiliated with
Conveyal. The existing R bindings r5r (IPEA) and
r5py are not dependencies here, but r5py's source was the
reference for how R5's Java API is used — credit where it is due.



