Migrate to java

This commit is contained in:
Andras Schmelczer 2026-02-11 21:33:14 +00:00
parent 1588c01b19
commit dccc1e439d
8 changed files with 243 additions and 192 deletions

View file

@ -21,6 +21,8 @@ services:
environment:
POCKETBASE_URL: http://pocketbase:8090
POCKETBASE_ADMIN_EMAIL: ${POCKETBASE_ADMIN_EMAIL:-}
POCKETBASE_ADMIN_PASSWORD: ${POCKETBASE_ADMIN_PASSWORD:-}
SCREENSHOT_URL: http://screenshot:8002
OLLAMA_URL: http://host.docker.internal:11434
R5_URL: http://r5:8003
@ -86,23 +88,23 @@ services:
start_period: 5s
r5:
build: ./r5-service
build: ./r5-java
ports:
- "8003:8003"
- "8004:8003"
networks:
- dev-network
volumes:
- ./property-data/transit:/data/transit
- r5-cache:/root/.cache/r5py
- r5-network:/data/network
- ./property-data/transit:/data/transit:ro
environment:
TRANSIT_DATA_DIR: /data/transit
DATA_DIR: /data/transit
NETWORK_CACHE_DIR: /data/network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8003/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 600s
init: true
retries: 5
start_period: 300s
volumes:
pb-data:
@ -110,7 +112,7 @@ volumes:
cargo-target:
frontend-node-modules:
screenshot-cache:
r5-cache:
r5-network:
networks:
dev-network:

33
r5-java/Dockerfile Normal file
View file

@ -0,0 +1,33 @@
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# Download pre-built R5 fat JAR from GitHub Releases
ADD https://github.com/conveyal/r5/releases/download/v7.5/r5-v7.5-all.jar /app/lib/r5.jar
# Download Javalin + Gson + SLF4J
ADD https://repo1.maven.org/maven2/io/javalin/javalin/6.4.0/javalin-6.4.0.jar /app/lib/javalin.jar
ADD https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar /app/lib/gson.jar
ADD https://repo1.maven.org/maven2/org/slf4j/slf4j-simple/2.0.16/slf4j-simple-2.0.16.jar /app/lib/slf4j-simple.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-server/11.0.24/jetty-server-11.0.24.jar /app/lib/jetty-server.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-util/11.0.24/jetty-util-11.0.24.jar /app/lib/jetty-util.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-http/11.0.24/jetty-http-11.0.24.jar /app/lib/jetty-http.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-io/11.0.24/jetty-io-11.0.24.jar /app/lib/jetty-io.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-servlet/11.0.24/jetty-servlet-11.0.24.jar /app/lib/jetty-servlet.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-security/11.0.24/jetty-security-11.0.24.jar /app/lib/jetty-security.jar
ADD https://repo1.maven.org/maven2/jakarta/servlet/jakarta.servlet-api/5.0.0/jakarta.servlet-api-5.0.0.jar /app/lib/servlet-api.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-jetty-server/11.0.24/websocket-jetty-server-11.0.24.jar /app/lib/ws-server.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-jetty-api/11.0.24/websocket-jetty-api-11.0.24.jar /app/lib/ws-api.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-core-server/11.0.24/websocket-core-server-11.0.24.jar /app/lib/ws-core-server.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-core-common/11.0.24/websocket-core-common-11.0.24.jar /app/lib/ws-core-common.jar
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-servlet/11.0.24/websocket-servlet-11.0.24.jar /app/lib/ws-servlet.jar
ADD https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/2.1.0/kotlin-stdlib-2.1.0.jar /app/lib/kotlin-stdlib.jar
COPY src/ src/
RUN javac -cp "lib/*" -d out src/main/java/propertymap/App.java
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/lib/ /app/lib/
COPY --from=build /app/out/ /app/out/
ENTRYPOINT ["java", "-Xmx4g", "-cp", "out:lib/*", "propertymap.App"]

37
r5-java/build.gradle Normal file
View file

@ -0,0 +1,37 @@
plugins {
id 'java'
id 'application'
id 'com.github.johnrengelman.shadow' version '8.1.1'
}
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
application {
mainClass = 'propertymap.App'
}
dependencies {
implementation 'com.conveyal:r5:7.2'
implementation 'io.javalin:javalin:6.4.0'
implementation 'com.google.code.gson:gson:2.11.0'
implementation 'org.slf4j:slf4j-simple:2.0.16'
}
jar {
manifest {
attributes 'Main-Class': 'propertymap.App'
}
}
shadowJar {
archiveClassifier = ''
mergeServiceFiles()
}

1
r5-java/settings.gradle Normal file
View file

@ -0,0 +1 @@
rootProject.name = 'r5-service'

View file

@ -0,0 +1,161 @@
package propertymap;
import com.conveyal.r5.analyst.FreeFormPointSet;
import com.conveyal.r5.analyst.TravelTimeComputer;
import com.conveyal.r5.analyst.cluster.RegionalTask;
import com.conveyal.r5.api.util.LegMode;
import com.conveyal.r5.api.util.TransitModes;
import com.conveyal.r5.transit.TransportNetwork;
import com.google.gson.Gson;
import io.javalin.Javalin;
import io.javalin.http.Context;
import java.io.File;
import java.time.LocalDate;
import java.util.EnumSet;
public class App {
private static TransportNetwork network;
private static final Gson gson = new Gson();
static class TravelTimeRequest {
double[] origin; // [lat, lon]
double[][] destinations; // [[lat, lon], ...]
String mode; // "transit", "car", "bicycle", "walking"
}
static class TravelTimeResponse {
double[] travel_times; // minutes, -1 = unreachable
}
public static void main(String[] args) throws Exception {
String dataDir = System.getenv("DATA_DIR");
if (dataDir == null) dataDir = "/data/transit";
String networkCacheDir = System.getenv("NETWORK_CACHE_DIR");
if (networkCacheDir == null) networkCacheDir = "/data/network";
System.out.println("Loading transport network from " + dataDir);
System.out.println("Network cache dir: " + networkCacheDir);
File cacheFile = new File(networkCacheDir, "network.dat");
if (cacheFile.exists()) {
System.out.println("Loading cached network from " + cacheFile);
network = TransportNetwork.read(cacheFile);
} else {
System.out.println("Building network (first run, this takes a few minutes)...");
network = TransportNetwork.fromDirectory(new File(dataDir));
new File(networkCacheDir).mkdirs();
network.write(cacheFile);
System.out.println("Network cached to " + cacheFile);
}
System.out.println("Transport network loaded successfully");
Javalin app = Javalin.create().start(8003);
app.get("/health", ctx -> ctx.result("ok"));
app.post("/travel-times", App::handleTravelTimes);
System.out.println("R5 service listening on port 8003");
}
private static void handleTravelTimes(Context ctx) {
long t0 = System.currentTimeMillis();
TravelTimeRequest req = gson.fromJson(ctx.body(), TravelTimeRequest.class);
if (req.origin == null || req.origin.length != 2) {
ctx.status(400).result("origin must be [lat, lon]");
return;
}
if (req.destinations == null || req.destinations.length == 0) {
ctx.status(400).result("destinations must be non-empty array of [lat, lon]");
return;
}
String mode = req.mode != null ? req.mode : "transit";
// Build destination point set
double[] lats = new double[req.destinations.length];
double[] lons = new double[req.destinations.length];
for (int i = 0; i < req.destinations.length; i++) {
lats[i] = req.destinations[i][0];
lons[i] = req.destinations[i][1];
}
FreeFormPointSet destinations = new FreeFormPointSet(lats, lons);
// Build the regional task
RegionalTask task = new RegionalTask();
task.fromLat = req.origin[0];
task.fromLon = req.origin[1];
task.date = LocalDate.now();
task.percentiles = new int[]{50};
task.monteCarloDraws = 1;
switch (mode) {
case "car":
task.fromTime = 8 * 3600;
task.toTime = 8 * 3600 + 60;
task.maxTripDurationMinutes = 120;
task.accessModes = EnumSet.of(LegMode.CAR);
task.egressModes = EnumSet.of(LegMode.CAR);
task.directModes = EnumSet.of(LegMode.CAR);
task.transitModes = EnumSet.noneOf(TransitModes.class);
break;
case "bicycle":
task.fromTime = 8 * 3600;
task.toTime = 8 * 3600 + 60;
task.maxTripDurationMinutes = 90;
task.accessModes = EnumSet.of(LegMode.BICYCLE);
task.egressModes = EnumSet.of(LegMode.BICYCLE);
task.directModes = EnumSet.of(LegMode.BICYCLE);
task.transitModes = EnumSet.noneOf(TransitModes.class);
break;
case "walking":
task.fromTime = 8 * 3600;
task.toTime = 8 * 3600 + 60;
task.maxTripDurationMinutes = 60;
task.accessModes = EnumSet.of(LegMode.WALK);
task.egressModes = EnumSet.of(LegMode.WALK);
task.directModes = EnumSet.of(LegMode.WALK);
task.transitModes = EnumSet.noneOf(TransitModes.class);
break;
default: // transit
task.fromTime = 8 * 3600;
task.toTime = 8 * 3600 + 60; // single RAPTOR sweep
task.maxTripDurationMinutes = 90;
task.maxRides = 4;
task.accessModes = EnumSet.of(LegMode.WALK);
task.egressModes = EnumSet.of(LegMode.WALK);
task.directModes = EnumSet.of(LegMode.WALK);
task.transitModes = EnumSet.allOf(TransitModes.class);
break;
}
// Compute travel times
TravelTimeComputer computer = new TravelTimeComputer(task, network, destinations);
int[][] results = computer.computeTravelTimes();
// results[percentileIdx][destinationIdx] we only have 1 percentile (index 0)
TravelTimeResponse response = new TravelTimeResponse();
response.travel_times = new double[req.destinations.length];
int[] times = results[0]; // percentile 0 (the 50th percentile)
for (int i = 0; i < req.destinations.length; i++) {
if (i < times.length && times[i] != Integer.MAX_VALUE) {
response.travel_times[i] = times[i]; // already in minutes
} else {
response.travel_times[i] = -1; // unreachable
}
}
long elapsed = System.currentTimeMillis() - t0;
System.out.println("Travel times (" + mode + ") computed for " + req.destinations.length +
" destinations in " + elapsed + "ms");
ctx.json(response);
}
}

View file

@ -1,19 +0,0 @@
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# r5py needs a JVM to run the R5 routing engine
RUN apt-get update && \
apt-get install -y --no-install-recommends openjdk-21-jre-headless curl libexpat1 libgdal-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml .
RUN uv pip install --system --no-cache -r pyproject.toml
COPY main.py .
EXPOSE 8003
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8003"]

View file

@ -1,153 +0,0 @@
"""R5 travel time service — FastAPI wrapper around r5py.
Loads an OSM PBF + GTFS feeds at startup, then serves many-to-one
travel time queries via POST /travel-times.
"""
import datetime
import logging
import os
from pathlib import Path
import geopandas as gpd
import pandas as pd
import r5py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from shapely.geometry import Point
logger = logging.getLogger("r5-service")
logging.basicConfig(level=logging.INFO)
app = FastAPI(title="R5 Travel Time Service")
# Global transport network — loaded once at startup
_transport_network: r5py.TransportNetwork | None = None
@app.on_event("startup")
def load_network() -> None:
global _transport_network
data_dir = Path(os.environ.get("TRANSIT_DATA_DIR", "/data/transit"))
osm_files = list(data_dir.glob("*.osm.pbf"))
if not osm_files:
raise RuntimeError(f"No .osm.pbf file found in {data_dir}")
osm_pbf = osm_files[0]
gtfs_files = list(data_dir.glob("*.zip"))
logger.info(
"Loading transport network: OSM=%s, GTFS=%s",
osm_pbf.name,
[f.name for f in gtfs_files],
)
_transport_network = r5py.TransportNetwork(
osm_pbf=osm_pbf,
gtfs=gtfs_files if gtfs_files else None,
)
logger.info("Transport network loaded successfully")
# ── Request / Response models ────────────────────────────────────────────────
# r5py 1.x uses transport_modes for direct modes and access_modes for
# first-mile walking/cycling to transit.
MODE_CONFIGS = {
"transit": {
"transport_modes": [r5py.TransportMode.TRANSIT],
"access_modes": [r5py.TransportMode.WALK],
},
"car": {
"transport_modes": [r5py.TransportMode.CAR],
},
"bicycle": {
"transport_modes": [r5py.TransportMode.BICYCLE],
},
}
class TravelTimeRequest(BaseModel):
origins: list[list[float]] # [[lat, lon], ...]
destination: list[float] # [lat, lon]
mode: str = "transit"
departure_time: str | None = None # ISO 8601, defaults to next weekday 8am
class TravelTimeResponse(BaseModel):
travel_times: list[float | None] # minutes per origin, null if unreachable
# ── Endpoints ────────────────────────────────────────────────────────────────
@app.get("/health")
def health() -> dict:
if _transport_network is None:
raise HTTPException(status_code=503, detail="Network not loaded")
return {"status": "ok"}
@app.post("/travel-times", response_model=TravelTimeResponse)
def compute_travel_times(req: TravelTimeRequest) -> TravelTimeResponse:
if _transport_network is None:
raise HTTPException(status_code=503, detail="Network not loaded")
if req.mode not in MODE_CONFIGS:
raise HTTPException(
status_code=400,
detail=f"Invalid mode '{req.mode}'. Must be one of: {list(MODE_CONFIGS)}",
)
if not req.origins:
return TravelTimeResponse(travel_times=[])
# Parse departure time
if req.departure_time:
departure = datetime.datetime.fromisoformat(req.departure_time)
else:
# Default: next weekday at 8:00 AM
now = datetime.datetime.now()
departure = now.replace(hour=8, minute=0, second=0, microsecond=0)
# Advance to next weekday if weekend
while departure.weekday() >= 5:
departure += datetime.timedelta(days=1)
# Build origin GeoDataFrame (note: Point takes (lon, lat))
origin_points = [Point(lon, lat) for lat, lon in req.origins]
origins_gdf = gpd.GeoDataFrame(
{"id": range(len(origin_points))},
geometry=origin_points,
crs="EPSG:4326",
)
# Build destination GeoDataFrame
dest_lat, dest_lon = req.destination
dest_gdf = gpd.GeoDataFrame(
{"id": [0]},
geometry=[Point(dest_lon, dest_lat)],
crs="EPSG:4326",
)
mode_config = MODE_CONFIGS[req.mode]
# r5py 1.x: TravelTimeMatrix is instantiated directly and IS the result
result = r5py.TravelTimeMatrix(
_transport_network,
origins=origins_gdf,
destinations=dest_gdf,
departure=departure,
**mode_config,
)
# Build response: one travel time per origin
# r5py 1.x returns a GeoDataFrame with columns: from_id, to_id, travel_time
travel_times: list[float | None] = [None] * len(req.origins)
for _, row in result.iterrows():
origin_idx = int(row["from_id"])
tt = row["travel_time"]
if pd.notna(tt) and tt >= 0:
travel_times[origin_idx] = float(tt)
return TravelTimeResponse(travel_times=travel_times)

View file

@ -1,11 +0,0 @@
[project]
name = "r5-service"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"r5py>=0.8",
"fastapi>=0.115",
"uvicorn>=0.34",
"geopandas>=1.0",
"shapely>=2.0",
]