GISBoost / architektura Easy-R5

Easy-R5 · notatka architektoniczna

Jak QGIS rozmawia z R5

R5 to biblioteka Javy — bez CLI do macierzy czasów przejazdu i bez trybu serwerowego, który by je liczył. Każde istniejące wiązanie ładuje więc JVM do wnętrza własnego procesu i woła klasy R5 bezpośrednio: r5r przez rJava, r5py przez JPype. Easy-R5 nie może pójść tą drogą — r5r wymaga R, r5py ciągnie 16 pakietów pip. Zamiast tego wtyczka traktuje R5 jak każdy inny podproces Processing: uruchamia oficjalny jar przez jeden mały plik Javy, przekazuje zadanie w JSON-ie i czyta wynik z CSV oraz protokołu na stdout.

1
plik .java — cała powierzchnia Javy w projekcie
0
pakietów pip w easy_r5/
3
komendy runnera: build · matrix · info
1:43
macierz 1389 × 956 (Gdańsk, P50) — zweryfikowane vs R5 7.6

Przepływ danych

Dwa procesy, granica i kontrakt

Python przygotowuje wszystko, czego R5 nie policzy sam: reprojektuje warstwy punktowe do CSV, składa job.json, rozwiązuje ścieżki do JDK i jara, dobiera -Xmx do RAM-u. runner.py uruchamia proces potomny i od tej chwili tylko czyta jego stdout — linia PROGRESS d/t napędza pasek postępu, feedback.isCanceled() między liniami wywołuje kill(pid), DONE kończy przebieg. Java robi wyłącznie routing. Dostępność skumulowana, kontury izochron, statystyka strefowa, klasyfikacja i style liczą się z powrotem w Pythonie/QGIS.

PROCES 1 · INTERPRETER QGIS-A (PyQGIS) Processing RunTravelTimeMatrix job_spec.py percentyle ≤5 · tryby · daty points.py · network_cache CSV 4326 · sha256 + wersja R5 runner.py buduje komendę spawn procesu parser stdout → postęp mapuje kody ERROR kill(pid) = anuluj sprząta w finally matrix.py scala CSV z batchy warstwa QGIS + pola metody: r5_version… GRANICA PROCESU ↓ java -Xmx8g -cp r5-v7.6-all.jar EasyR5Runner.java job.json ↑ stdout: PROGRESS · DONE · ERROR + matrix_000.csv PROCES 2 · JVM TEMURIN 21 · OSOBNY PID · WŁASNY HEAP -Xmx · OOM UBIJA TYLKO TO EasyR5Runner.java jeden plik · JEP 330 single-file source launcher build matrix info com.conveyal.r5.* TransportNetwork RegionalTask TravelTimeComputer OneOriginResult r5-v7.6-all.jar oficjalny Conveyala niezmodyfikowany pętla po origins: 1 origin = 1 × TravelTimeComputer · ~16–40 ms/origin · network.dat wczytany raz (~1,2 s) FreeFormPointSet budowany raz na proces · pierwszy origin ~900 ms (linkowanie + EgressCostTable) recordAccessibility = false → dostępność liczy Python z macierzy (natywna ścieżka R5 tylko w Conveyal Analysis)
proces QGIS / Python proces JVM / R5 kontrakt: JSON → CSV + stdout
runner.py jest zawiasem. Wchodzi w niego wynik przygotowania (górny tor), on uruchamia proces potomny, a po stronie Javy pojedynczy plik EasyR5Runner.java woła klasy R5 i pisze CSV. Wynik wraca tym samym korytarzem (stdout + plik) i płynie dolnym torem do warstwy QGIS. Poniżej granicy nie ma ani linijki Pythona; powyżej — ani jednej JVM.
  1. job_spec.py waliduje parametry (percentyle ≤ 5 rosnąco, tryby, zakres dat, wyprowadza max_walk_time) → job.json w katalogu tymczasowym.
  2. points.py przerzuca warstwy QGIS do origins.csv / destinations.csv w EPSG:4326, ze stabilnymi id.
  3. network_cache.py znajduje network.dat po kluczu sha256(osm) + sha256(gtfs…) + wersja R5; niezgodna wersja = przebudowa.
  4. java_env.py bierze ścieżki JDK i jara z QSettings (weryfikacja SHA-256), wykrywa RAM, dobiera -Xmx = min(0,6 · RAM, 12 GB).
  5. runner.py składa komendę, uruchamia proces potomny, czyta stdout linia po linii: PROGRESS → pasek, ERROR <kod> → komunikat PL/EN, DONE → koniec.
  6. EasyR5Runner.java czyta job.json, buduje RegionalTask, w pętli po origin_range woła TravelTimeComputer, pisze wiersze CSV, streamuje postęp.
  7. matrix.py scala CSV z batchy → QgsVectorLayer z kompletem pól metody (r5_version, run_date, departure_time, percentile, modes).
Domenowa siatka bezpieczeństwa Po przebiegu runner raportuje RESULT transit_used_pairs=<n> — liczbę par OD szybszych niż dojście pieszo. Zero oznacza, że R5 policzył same trasy piesze (najczęściej: data bez aktywnych kursów GTFS, którą R5 przyjmuje bez błędu). Python przerywa wtedy z komunikatem. Ten dokładny błąd raz trafił na produkcję w tools/ (GZM, sierpień 2026) — stąd dwa niezależne mechanizmy: twarda walidacja daty przed startem i ten detektor po.

Nazewnictwo

Adapter? Wrapper? Jak to nazwać

To nie jest „wrapper” ani „binding” w sensie bibliotecznym. r5r i r5py owijają API R5 klasa po klasie i wystawiają je w innym języku, w tym samym procesie. Tu nie ma owijania API — jest własny, wąski kontrakt zadaniowy (trzy komendy), a R5 żyje po drugiej stronie granicy procesu i nigdy nie dotyka interpretera QGIS-a. Rozbicie na warstwy i precyzyjne słowa:

out-of-process bindingwiązanie przez proces potomny
Cały mechanizm. W opozycji do in-process (r5r = rJava, r5py = JPype), gdzie JVM ładuje się do wnętrza hosta. Tu JVM to osobny PID.
cienki adapter CLIEasyR5Runner.java
R5 nie ma CLI do macierzy ani dostępności — ten jeden plik jest tym CLI. Adaptuje bibliotekę Javy do interfejsu linii komend o stabilnym kontrakcie. Zakres: build, matrix, info (później itinerary).
sterownik procesueasy_r5/core/runner.py
Buduje linię komend, uruchamia dziecko, parsuje protokół stdout, robi pasek postępu i anulowanie (kill(pid)), mapuje kody ERROR na komunikaty, sprząta w finally.
runnernazwa w kodzie i w rozmowie
Tak jak w easy-OTP. Konkretniej: job runner, nie serwer — w przeciwieństwie do OTP, które easy-OTP odpala jako serwer HTTP. Nie „adapter”, nie „wrapper”, nie „bridge”.

W skrócie

Easy-R5 nie „owija” R5 jak r5r czy r5py — uruchamia oficjalny jar jako proces potomny, sterowany jednym małym plikiem Javy, który dostaje zadanie w JSON-ie i oddaje wynik w CSV plus protokół postępu na stdout.

To ten sam wzorzec, którym QGIS Processing woła GDAL (ogr2ogr, gdal_contour), SAGA i GRASS — podproces z wejściem i wyjściem plikowym, nie biblioteka in-process. Nie jest egzotyczny; jest natywny dla QGIS-a.

Alternatywy

Dlaczego nie r5r i nie r5py

Punkt wyjścia to twarde ograniczenie odziedziczone po easy-OTP: wtyczka musi działać na czystej instalacji QGIS — żadnego pip install, R, condy, Dockera. Pobieranie binariów przy setupie (JDK, jar) jest dozwolone — precedens DownloadJre. Instalowanie pakietów Pythona do interpretera QGIS-a — nie.

r5r / r5py — JVM W PROCESIE HOSTA (rJava / JPype) proces R / Python · wątek GUI JVM in-process · heap zamrożony na starcie sesji R5 OutOfMemoryError ✗ OOM R5 → pad całego QGIS-a ✗ -Xmx ustalony raz, przy pierwszym starcie JVM ✗ anulowanie = przerwać Javę z wątku GUI ✗ bootstrap kompilowanego wheela (jpype1) pod ABI QGIS-a EASY-R5 — JVM JAKO PROCES POTOMNY proces QGIS runner.py czyta stdout JVM potomny · -Xmx per run R5 OutOfMemoryError job.json ↓ stdout + CSV ↑ ✓ OOM R5 → ginie tylko podproces, QGIS żyje ✓ -Xmx per run, ustawiany w komendzie ✓ anulowanie = kill(pid), natychmiast i czysto ✓ zero zależności Pythona do zbootstrapowania
Różnica, o którą toczy się decyzja: gdzie stoi JVM. In-process (r5r, r5py) współdzieli pamięć i cykl życia z QGIS-em — a pipeline r5r w tools/ naprawdę padał na 12 GB sterty przy Warszawie. Proces potomny izoluje awarię, heap i anulowanie.
Cztery kandydatury oceniane w ADR-0001 / docs/notes/bindings-comparison.md.
Kryterium r5r r5py JPype-only jar + runner (nasz)
Działa na czystym QGIS wymaga R 16 pakietów pip ~ 1 dep kompilowany
Java do utrzymania brak brak brak ~1 plik
Crash JVM kładzie QGIS tak nie
Heap per run
Anulowanie = kill procesu
Potrzebny JDK (nie JRE) JDK 21 JDK 21 JDK/JRE 21 JDK 21 (lub prekompilacja)
Zmiana API R5 psuje… upstream upstream nas nas

r5r — odpada natychmiast

Wymaga R + rJava. CLAUDE.md: „ZERO R, ZERO GRASS”. R5 żyje w R przez in-process JVM przez własny jar r5r_core (~250 KB Javy w kształcie ramek danych R: RDataFrame, build przeciw JRI.jar) — nieużywalny bez R nawet przy kompilacji. Zostaje jako referencja zachowania: wszystkie skrypty w tools/ go używają, więc definiuje, jaki wynik ma wychodzić (M4 odtwarza gdansk_service_accessibility.csv co do wiersza).

r5py — odpada jako zależność runtime

16 pakietów pip, w tym kompilowane: jpype1, rasterio, simplification, scikit-learn, plus geopandas. To drzewo w kształcie condy, nie „jeden wąski wyjątek” jak openpyxl. API r5py opiera się też mocno na semantyce GeoPandas, której wtyczka QGIS nie potrzebuje. Zostaje jako najlepszy wzorzec „jak wołać klasy R5” — jego src/r5py/r5/*.py to faktyczna dokumentacja kolejności wywołań. Czytać, nie shippować.

JPype-only — plan B, udokumentowany, nie wybrany

pip install jpype1 jako jedyny wyjątek, potem wołać com.conveyal.r5.* z Pythona samemu. Zero Javy do napisania, jedna zależność. Odrzucone jako domyślne, bo jpype1 to kompilowany wheel, który musi trafić w dokładne ABI Pythona QGIS-a (3.9 w 3.22 LTR, 3.12 w nowszych) i platformę — dużo bardziej kruche niż pure-Pythonowy trik z openpyxl. I bo JVM w procesie QGIS-a znaczy: OOM kładzie QGIS, heap zamrożony na sesję, brak czystego anulowania.

Serwer debug R5 — odpada

PointToPointRouterServer to jedyny „gotowy” serwer w jarze, ale robi routing punkt-do-punktu do debugowania — bez endpointu macierzy i dostępności. Oddaje dokładnie tę zdolność, po którą bierzemy R5.

Kontrakt runnera

Co przechodzi przez granicę

Jedna linia = jedna wiadomość, UTF-8, \n. Python mapuje kody ERROR na komunikaty dla użytkownika — żaden stack trace Javy nie trafia do GUI.

stdout — protokół INFO <tekst> # feedback.pushInfo PROGRESS <done> <total> # pasek postępu, ≥ co 1 s WARN <kod> <tekst> # np. NO_POINTS_LINKED ERROR <kod> <tekst> # kod wyjścia 1 RESULT <klucz>=<wartość> # pojedynczy fakt (command=info) DONE <ścieżka> <wiersze> # ostatnia linia, kod wyjścia 0 # kody: NETWORK_VERSION_MISMATCH · NETWORK_READ_FAILED · OUT_OF_MEMORY # NO_POINTS_LINKED · DATE_NO_SERVICE · BAD_JOB_SPEC · IO_ERROR
job.json — command: matrix (skrót) { "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, // nigdy null — Python wyprowadza "transit_modes": ["TRAM", "RAIL", "BUS", "FERRY"], "out_csv": ".../matrix_000.csv" } # CSV out (format długi, nagłówki jak r5r): # from_id,to_id,travel_time_p50 # Integer.MAX_VALUE nigdy nie trafia do pliku → NULL w warstwie

EasyR5Runner.java musi zostać jednym plikiem — single-file source launcher (JEP 330) w Javie 21 kompiluje jedną jednostkę kompilacji; multi-file source programs to dopiero Java 22+. Klasy pakietowo-prywatne w tym samym pliku są OK. Mapowanie na RegionalTask jest sprawdzone w spike'u i nie zmienia się bez powodu.

Koszty

Co za to płacimy i warunek odwrócenia

Warunek odwrócenia · ADR-0001 Plan B (JPype) zostaje udokumentowany. Przełącz na niego tylko jeśli runner nie zmieści się w jednym pliku oraz prawdziwy build okaże się gorszy niż JVM in-process. Nic poza warstwą transportową easy_r5/core/ nie zależy od tego, którego z dwóch użyto.