From dccc1e439df884f517a5604f670ca5cdd9909ad5 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 11 Feb 2026 21:33:14 +0000 Subject: [PATCH] Migrate to java --- docker-compose.yml | 20 +-- r5-java/Dockerfile | 33 +++++ r5-java/build.gradle | 37 +++++ r5-java/settings.gradle | 1 + r5-java/src/main/java/propertymap/App.java | 161 +++++++++++++++++++++ r5-service/Dockerfile | 19 --- r5-service/main.py | 153 -------------------- r5-service/pyproject.toml | 11 -- 8 files changed, 243 insertions(+), 192 deletions(-) create mode 100644 r5-java/Dockerfile create mode 100644 r5-java/build.gradle create mode 100644 r5-java/settings.gradle create mode 100644 r5-java/src/main/java/propertymap/App.java delete mode 100644 r5-service/Dockerfile delete mode 100644 r5-service/main.py delete mode 100644 r5-service/pyproject.toml diff --git a/docker-compose.yml b/docker-compose.yml index f0c7277..1c85931 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/r5-java/Dockerfile b/r5-java/Dockerfile new file mode 100644 index 0000000..73a6e41 --- /dev/null +++ b/r5-java/Dockerfile @@ -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"] diff --git a/r5-java/build.gradle b/r5-java/build.gradle new file mode 100644 index 0000000..b23e558 --- /dev/null +++ b/r5-java/build.gradle @@ -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() +} diff --git a/r5-java/settings.gradle b/r5-java/settings.gradle new file mode 100644 index 0000000..7677d00 --- /dev/null +++ b/r5-java/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'r5-service' diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java new file mode 100644 index 0000000..ce57d0a --- /dev/null +++ b/r5-java/src/main/java/propertymap/App.java @@ -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); + } +} diff --git a/r5-service/Dockerfile b/r5-service/Dockerfile deleted file mode 100644 index da26086..0000000 --- a/r5-service/Dockerfile +++ /dev/null @@ -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"] diff --git a/r5-service/main.py b/r5-service/main.py deleted file mode 100644 index 45e2af3..0000000 --- a/r5-service/main.py +++ /dev/null @@ -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) diff --git a/r5-service/pyproject.toml b/r5-service/pyproject.toml deleted file mode 100644 index e475054..0000000 --- a/r5-service/pyproject.toml +++ /dev/null @@ -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", -]