Easy-R5 · architecture note
How QGIS talks to R5
R5 is a Java library — with no CLI for travel-time matrices and no server mode that would compute them. Every existing binding therefore loads a JVM inside its own process and calls R5's classes directly: r5r through rJava, r5py through JPype. Easy-R5 can't take that route — r5r needs R, r5py pulls in 16 pip packages. Instead the plugin treats R5 like any other Processing subprocess: it runs the official jar through one small Java file, hands it a job as JSON, and reads the result from a CSV plus a stdout protocol.
- 1
.javafile — the entire Java surface of the project- 0
pippackages ineasy_r5/- 3
- runner commands:
build·matrix·info - 1:43
- 1389 × 956 matrix (Gdańsk, P50) — verified against R5 7.6
Data flow
Two processes, a boundary and a contract
Python prepares everything R5 won't compute itself: it reprojects the
point layers to CSV, assembles job.json, resolves the paths
to the JDK and the jar, sizes -Xmx to available RAM.
runner.py starts the child process and from then on only
reads its stdout — a PROGRESS d/t line drives the
progress bar, feedback.isCanceled() between lines triggers
kill(pid), DONE ends the run. Java does
routing only. Cumulative accessibility, isochrone
contouring, zonal statistics, classification and styling are all computed
back in Python/QGIS.
EasyR5Runner.java — calls R5's classes and writes the
CSV. The result comes back through the same corridor (stdout + file) and
flows along the bottom track to the QGIS layer. Below the boundary there
is not one line of Python; above it, not one JVM.
job_spec.pyvalidates the parameters (percentiles ≤ 5 ascending, modes, date range, derivesmax_walk_time) →job.jsonin a temp directory.points.pyexports the QGIS layers toorigins.csv/destinations.csvin EPSG:4326, with stableids.network_cache.pyfindsnetwork.datby the keysha256(osm) + sha256(gtfs…) + R5 version; a version mismatch = rebuild.java_env.pytakes the JDK and jar paths from QSettings (SHA-256 verified), detects RAM, sizes-Xmx = min(0.6 · RAM, 12 GB).runner.pyassembles the command, starts the child process, reads stdout line by line:PROGRESS→ bar,ERROR <code>→ an EN/PL message,DONE→ end.EasyR5Runner.javareadsjob.json, builds aRegionalTask, loops overorigin_rangecallingTravelTimeComputer, writes CSV rows, streams progress.matrix.pymerges the batch CSVs → aQgsVectorLayerwith the full set of method fields (r5_version,run_date,departure_time,percentile,modes).
RESULT transit_used_pairs=<n> —
the number of OD pairs faster than walking. Zero means R5 computed
walk-only routes (usually: a date with no active GTFS trips,
which R5 accepts without an error). Python then aborts with a message.
This exact bug once shipped to production in tools/
(GZM, August 2026) — hence two independent mechanisms: a hard date
check before the start and this detector after.
Naming
Adapter? Wrapper? What to call it
This is not a “wrapper” or a “binding” in the library sense. r5r and r5py wrap R5's API class by class and expose it in another language, in the same process. There is no API wrapping here — there is a narrow, purpose-built job contract (three commands), and R5 lives on the far side of a process boundary and never touches the QGIS interpreter. The layers, and the precise words:
- out-of-process bindinga binding across a child process
- The whole mechanism. As opposed to in-process (r5r = rJava, r5py = JPype), where the JVM loads inside the host. Here the JVM is a separate PID.
- thin CLI adapterEasyR5Runner.java
- R5 has no CLI for matrices or accessibility — this one file is
that CLI. It adapts a Java library to a command-line interface with a
stable contract. Scope:
build,matrix,info(itinerarylater). - process drivereasy_r5/core/runner.py
- Builds the command line, starts the child, parses the stdout protocol,
drives the progress bar and cancellation (
kill(pid)), mapsERRORcodes to messages, cleans up infinally. - runnerthe name in the code and in conversation
- Same as in easy-OTP. More precisely: a job runner, not a server — unlike OTP, which easy-OTP launches as an HTTP server. Not an “adapter”, not a “wrapper”, not a “bridge”.
In short
Easy-R5 does not “wrap” R5 the way r5r or r5py do — it runs the official jar as a child process, driven by one small Java file that receives a job as JSON and returns the result as a CSV plus a progress protocol on stdout.
It is the same pattern QGIS Processing uses to call GDAL (ogr2ogr,
gdal_contour), SAGA and GRASS — a subprocess with file input
and output, not an in-process library. It is not exotic; it is native to
QGIS.
Alternatives
Why not r5r and not r5py
The starting point is a hard constraint inherited from easy-OTP: the
plugin must run on a stock QGIS install — no
pip install, no R, no conda, no Docker. Downloading
binaries at setup (the JDK, the jar) is allowed —
DownloadJre is the precedent. Installing Python
packages into the QGIS interpreter — no.
tools/ really did OOM at 12 GB of heap
on Warsaw. A child process isolates the crash, the heap and cancellation.
| Criterion | r5r | r5py | JPype-only | jar + runner (ours) |
|---|---|---|---|---|
| Runs on a stock QGIS | ✗ needs R | ✗ 16 pip packages | ~ 1 compiled dep | ✓ |
| Java to maintain | none | none | none | ~1 file |
| JVM crash takes QGIS down | — | — | ✗ yes | ✓ no |
| Heap per run | — | — | ✗ | ✓ |
| Cancel = kill the process | — | — | ✗ | ✓ |
| Needs a JDK (not a JRE) | JDK 21 | JDK 21 | JDK/JRE 21 | JDK 21 (or pre-compile) |
| An R5 API change breaks… | upstream | upstream | ours | ours |
r5r — ruled out immediately
Needs R + rJava. CLAUDE.md: “ZERO R,
ZERO GRASS”. R5 lives inside R through an in-process JVM via its own jar,
r5r_core (~250 KB of Java shaped around R data frames:
RDataFrame, built against JRI.jar) — unusable
without R even at build time. It stays as a behaviour
reference: every script in tools/ uses it, so it
defines what the output should look like (M4 reproduces
gdansk_service_accessibility.csv row for row).
r5py — ruled out as a runtime dependency
16 pip packages, several of them compiled: jpype1,
rasterio, simplification,
scikit-learn, plus geopandas. That is a
conda-shaped tree, not “one narrow exception” like openpyxl.
r5py's API also leans hard on GeoPandas semantics the QGIS plugin does
not need. It stays as the best reference for “how to call R5's
classes” — its src/r5py/r5/*.py is effectively
documentation of the call order. Read it, don't ship it.
JPype-only — plan B, documented, not chosen
pip install jpype1 as the single exception, then call
com.conveyal.r5.* from Python ourselves. No Java to write,
one dependency. Rejected as the default because jpype1 is a
compiled wheel that has to match QGIS's exact Python ABI (3.9 on
3.22 LTR, 3.12 on newer) and platform — far more fragile than the
pure-Python openpyxl trick. And because a JVM inside the
QGIS process means: an OOM takes QGIS down, the heap is frozen for the
session, there is no clean cancel.
R5's debug server — ruled out
PointToPointRouterServer is the only “ready-made” server in
the jar, but it does point-to-point routing for debugging — with no
matrix or accessibility endpoint. It gives up exactly the capability R5
is chosen for.
The runner contract
What crosses the boundary
One line = one message, UTF-8, \n. Python maps
ERROR codes to user-facing messages — no Java stack trace ever
reaches the GUI.
EasyR5Runner.java must stay one file — the
single-file source launcher (JEP 330) on Java 21 compiles one
compilation unit; multi-file source programs only arrived in
Java 22+. Package-private classes in the same file are fine. The
mapping onto RegionalTask is the one verified in the spike and
does not change without a reason.
Costs
What it costs, and the reversal condition
-
We maintain a little Java against an API upstream openly
refuses to keep stable. Mitigation: pin the R5 version (ADR-0002), keep
the runner's surface minimal,
command=infoas a smoke test after every bump. - A JDK (~180 MB), not a JRE (~45 MB) — unless we ship a compiled class, which brings back a build step.
-
One file = one compilation unit up to Java 21. The
runner must stay one file; if it outgrows that — a separate MIT repo
with a jar, like
easy-GTFS-RT. -
We take easy-OTP's pattern, not OTP's semantics. Process
management, the progress bar, cancellation,
DownloadJre→ copied. OTP semantics — not.
easy_r5/core/
transport layer depends on which of the two is used.