GISBoost / Easy-R5 architecture

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
.java file — the entire Java surface of the project
0
pip packages in easy_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.

PROCESS 1 · QGIS PYTHON INTERPRETER (PyQGIS) Processing RunTravelTimeMatrix job_spec.py percentiles ≤5 · modes · dates points.py · network_cache CSV 4326 · sha256 + R5 version runner.py builds the command spawns the process parses stdout → progress maps ERROR codes kill(pid) = cancel cleans up in finally matrix.py merges the batch CSVs QGIS layer + method fields: r5_version… PROCESS BOUNDARY ↓ java -Xmx8g -cp r5-v7.6-all.jar EasyR5Runner.java job.json ↑ stdout: PROGRESS · DONE · ERROR + matrix_000.csv PROCESS 2 · JVM TEMURIN 21 · SEPARATE PID · OWN -Xmx HEAP · OOM KILLS ONLY THIS EasyR5Runner.java one file · JEP 330 single-file source launcher build matrix info com.conveyal.r5.* TransportNetwork RegionalTask TravelTimeComputer OneOriginResult r5-v7.6-all.jar official, from Conveyal unmodified loop over origins: 1 origin = 1 × TravelTimeComputer · ~16–40 ms/origin · network.dat read once (~1.2 s) FreeFormPointSet built once per process · first origin ~900 ms (linking + EgressCostTable) recordAccessibility = false → Python computes accessibility from the matrix (R5's native path only works in Conveyal Analysis)
QGIS / Python process JVM / R5 process the contract: JSON → CSV + stdout
runner.py is the hinge. The prepared inputs go into it (top track), it starts the child process, and on the Java side a single file — 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.
  1. job_spec.py validates the parameters (percentiles ≤ 5 ascending, modes, date range, derives max_walk_time) → job.json in a temp directory.
  2. points.py exports the QGIS layers to origins.csv / destinations.csv in EPSG:4326, with stable ids.
  3. network_cache.py finds network.dat by the key sha256(osm) + sha256(gtfs…) + R5 version; a version mismatch = rebuild.
  4. java_env.py takes the JDK and jar paths from QSettings (SHA-256 verified), detects RAM, sizes -Xmx = min(0.6 · RAM, 12 GB).
  5. runner.py assembles the command, starts the child process, reads stdout line by line: PROGRESS → bar, ERROR <code> → an EN/PL message, DONE → end.
  6. EasyR5Runner.java reads job.json, builds a RegionalTask, loops over origin_range calling TravelTimeComputer, writes CSV rows, streams progress.
  7. matrix.py merges the batch CSVs → a QgsVectorLayer with the full set of method fields (r5_version, run_date, departure_time, percentile, modes).
Domain safety net After a run the runner reports 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 (itinerary later).
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)), maps ERROR codes to messages, cleans up in finally.
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.

r5r / r5py — JVM INSIDE THE HOST PROCESS (rJava / JPype) R / Python process · GUI thread in-process JVM · heap frozen at session start R5 OutOfMemoryError ✗ R5 OOM → the whole QGIS goes down ✗ -Xmx fixed once, at first JVM start ✗ cancel = interrupt Java from the GUI thread ✗ bootstrap a compiled wheel (jpype1) against QGIS's ABI EASY-R5 — JVM AS A CHILD PROCESS QGIS process runner.py reads stdout child JVM · -Xmx per run R5 OutOfMemoryError job.json ↓ stdout + CSV ↑ ✓ R5 OOM → only the child dies, QGIS lives ✓ -Xmx per run, set on the command line ✓ cancel = kill(pid), instant and clean ✓ zero Python dependencies to bootstrap
The difference the decision turns on: where the JVM stands. In-process (r5r, r5py) shares memory and lifecycle with QGIS — and the r5r pipeline in tools/ really did OOM at 12 GB of heap on Warsaw. A child process isolates the crash, the heap and cancellation.
Four candidates evaluated in ADR-0001 / docs/notes/bindings-comparison.md.
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.

stdout — protocol INFO <text> # feedback.pushInfo PROGRESS <done> <total> # progress bar, at least every 1 s WARN <code> <text> # e.g. NO_POINTS_LINKED ERROR <code> <text> # exit code 1 RESULT <key>=<value> # a single fact (command=info) DONE <path> <rows> # last line, exit code 0 # codes: NETWORK_VERSION_MISMATCH · NETWORK_READ_FAILED · OUT_OF_MEMORY # NO_POINTS_LINKED · DATE_NO_SERVICE · BAD_JOB_SPEC · IO_ERROR
job.json — command: matrix (excerpt) { "command": "matrix", "network": ".../network.dat", "origins": ".../origins.csv", "destinations": ".../destinations.csv", "origin_range": [0, 500], "date": "2026-08-25", "departure_time": "07:00", "time_window_minutes": 120, "percentiles": [50], "max_trip_duration_minutes": 90, "max_walk_time_minutes": 90, // never null — Python derives it "transit_modes": ["TRAM", "RAIL", "BUS", "FERRY"], "out_csv": ".../matrix_000.csv" } # CSV out (long format, r5r-style headers): # from_id,to_id,travel_time_p50 # Integer.MAX_VALUE never reaches the file → NULL in the layer

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

Reversal condition · ADR-0001 Plan B (JPype) stays documented. Switch to it only if the runner cannot stay in one file and a real build turns out worse than an in-process JVM. Nothing outside the easy_r5/core/ transport layer depends on which of the two is used.