OpenTripPlannerとGTFSを利用して独自の乗り換え案内を作ってみた

#はじめに
OpenTripPlanner(以下、OTP)を経路探索エンジンとして利用して、独自の乗り換え案内サイト([欧亜大陸鉄道](https://ter.fun))を作成したのだが、OTPを経路探索エンジンとしてのみ利用する方法の日本語情報があまり書かれていなさそうなので（英語の情報もあまりないけど...）、記事としてまとめてみた。
また地図データを登録せず時刻表データだけを登録しているので、そういった使い方の参考にもどうぞ。
## OpenTripPlanner とは
[OpenTripPlanner](https://www.opentripplanner.org/) はオープンソースの統合経路探索アプリケーション。
[OpenStreetMap](https://www.openstreetmap.org/)やGTFS(後述)データを取り込んで徒歩や乗り物を含めた経路探索、いわゆる乗換案内を行うことができる。
## GTFSとは
[GTFS](https://developers.google.com/transit/gtfs)は、Googleさんが作った公共交通機関の経路と運行情報のフォーマット。ひとことで言えば時刻表データで、近年オープンデータとして世界中の交通機関がデータを公開している。
## 作ったサイト[欧亜大陸鉄道](https://ter.fun)の紹介
戦前(1939年ごろ)、まだジェット機が無かったころ、一枚の切符で日本から欧州まで鉄道と船で移動することができたらしい。そんな時代の鉄道の乗り換え案内を行うことができる、別段特に何の役にも立たない趣味サイトを作っている。
世界中の鉄道の時刻表を集めてきて、東京からロンドン・パリまでの鉄道幹線（日本→朝鮮→旧満州→旧ソ連→ポーランド→ドイツ→ベルギー→イギリス/フランス）のデータが登録されている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635639/e3c720c6-c925-5d59-8c3a-431f21b36740.png)
# OTPの使い方
普通にウェブアプリケーションとしてOTPを立ち上げる方法は他の記事を参照していただくとして、今回は下記に特化して説明する。

- OTPをアプリケーションではなく、Javaのライブラリとして使用する
- OTPに地図データを登録せず、GTFSデータだけを登録して使用する

## 準備
下記バージョンの環境を前提とする。

- JDK 1.8 以上
- OpenTripPlanner 1.4

Mavenプロジェクトを作成し、pom.xmlファイルに下記情報を追加。
レポジトリを追加しているのは、OTPが [OneBusAway](https://onebusaway.org/)、[GeoTools](https://geotools.org/)という別のOSSを参照しているため。

```xml:pom.xml
<repositories>
  <repository>
    <id>onebusaway-releases</id>
    <name>Onebusaway Releases Repo</name>
    <url>http://nexus.onebusaway.org/content/repositories/releases/</url>
  </repository>
  <repository>
    <id>osgeo</id>
    <name>OSGeo Release Repository</name>
    <url>https://repo.osgeo.org/repository/release/</url>
  </repository>
</repositories>

<dependencies>
  <dependency>
    <groupId>org.opentripplanner</groupId>
    <artifactId>otp</artifactId>
    <version>1.4.0</version>
  </dependency>
</dependencies>
```

## Graphの作成
OTPは道路や交通機関の経路、時刻表データをGraphというオブジェクトに格納する。
しかしながら、Graphオブジェクトを作成する前にzip化されたGTFSデータを読み込み、GtfsBundleオブジェクトを作成する必要がある。
OTPは複数のGTFSファイルを読み込むことができるので、各ファイルに一意のIDを付与する必要がある。(feedIdを設定している部分)

```java:
GtfsBundle gtfsBundle = new GtfsBundle(file);
gtfsBundle.setTransfersTxtDefinesStationPaths(true);
GtfsFeedId feedId = new GtfsFeedId.Builder().id("id").build();
gtfsBundle.setFeedId(feedId);
```

作成されたGtfsBundleは一つのListに統合した後(gtfsBundles)、Graphオブジェクトに登録する。

```java:
GtfsModule gtfsModule = new GtfsModule(gtfsBundles);
Graph graph = new Graph();
gtfsModule.buildGraph(graph, null);
graph.summarizeBuilderAnnotations();
graph.index(new DefaultStreetVertexIndexFactory());
```
## 経路探索
まず、出発地、到着地、出発（or 到着）時間などの探索条件を設定するRoutingRequestオブジェクトを生成する。
本来は地図データを利用した経路探索アプリケーションであるため、駅でも座標を指定しなくてはならない。

```java:
RoutingRequest routingRequest = new RoutingRequest();
routingRequest.setNumItineraries(3); // 探索結果の候補数
// 探索する基準時間を設定
LocalDateTime ldt = LocalDateTime.parse("1939-08-01T13:00");
routingRequest.dateTime = ldt.atZone(ZoneId.systemDefault()).toInstant().getEpochSecond();
routingRequest.setArriveBy(false); // 既定は出発時間指定だが、Trueとすると到着時間を指定できる
// 出発地と到着地を緯度/経度で指定
routingRequest.from = new GenericLocation(35.3369, 139.44699); // 辻堂駅
routingRequest.to = new GenericLocation(35.17096, 136.88232); // 名古屋駅
routingRequest.ignoreRealtimeUpdates = true; //GTFSRealtime情報を無視
routingRequest.reverseOptimizing = true; // 探索結果から逆方向に探索し直し、最遅出発時間を探索
// GTFSデータのみで探索を行う設定
routingRequest.setModes(new TraverseModeSet(TraverseMode.TRANSIT));
routingRequest.onlyTransitTrips = true;
// Graphオブジェクトをセット
routingRequest.setRoutingContext(graph);
```

RoutingRequesオブジェクトとGraphオブジェクトを利用して探索を行う。

```java:
Router router = new Router("OTP", graph);
List<GraphPath> paths = new GraphPathFinder(router).getPaths(routingRequest);
TripPlan tripPlan = GraphPathToTripPlanConverter.generatePlan(paths, routingRequest);
```

TripPlanオブジェクトに探索結果が格納されている。
TripPlanのitineraryプロパティで取得されるListに、探索結果の候補Itineraryオブジェクトが格納されている。
Itineraryのlegプロパティで取得されるListに、経路情報のLegオブジェクトが乗り換え順に格納されている。

# まとめ
## 探索結果
僕のサイト [欧亜大陸鉄道](https://ter.fun) のGTFSデータ(今から約80年前の1939年12月の東海道線の時刻表データ)を使用して、1939年8月1日午後1時の辻堂駅から名古屋駅までの経路を探索してみる。
結果は下記のようになった。13時08分の列車（ちなみに米原行の普通）を逃すと、横浜、大船に戻って特急さくら下関行、急行大阪行を捕まえた方が早く名古屋に着くらしい。
なお、1939年当時の各駅の時刻表などは、[欧亜大陸鉄道](https://ter.fun) 内の各路線図で駅名をクリックすると確認できる。([辻堂駅](https://ter.fun/ja/station/jk%7Ctsujido)、[横浜駅](https://ter.fun/ja/station/jk%7Cyokohama)、[大船駅](https://ter.fun/ja/station/jk%7Cofuna))

```console
13:08 出発 - 18:28 到着 (320分)
> 辻堂 13:08 発 - 沼津 14:46 着
> 沼津 15:04 発 - 名古屋 18:28 着
13:18 出発 - 19:00 到着 (342分)
> 辻堂 13:18 発 - 横浜 13:50 着
> 横浜 13:58 発 - 名古屋 19:00 着
14:08 出発 - 19:38 到着 (330分)
> 辻堂 14:08 発 - 大船 14:20 着
> 大船 14:26 発 - 名古屋 19:38 着
```

## 全ソースコード
今回のソースの全文はこんな感じ。

```java:OtpTest.java
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Calendar;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.opentripplanner.api.model.TripPlan;
import org.opentripplanner.api.resource.GraphPathToTripPlanConverter;
import org.opentripplanner.common.model.GenericLocation;
import org.opentripplanner.graph_builder.model.GtfsBundle;
import org.opentripplanner.graph_builder.module.GtfsFeedId;
import org.opentripplanner.graph_builder.module.GtfsModule;
import org.opentripplanner.model.FeedScopedId;
import org.opentripplanner.model.Route;
import org.opentripplanner.model.Stop;
import org.opentripplanner.model.Trip;
import org.opentripplanner.routing.core.RoutingRequest;
import org.opentripplanner.routing.core.TraverseMode;
import org.opentripplanner.routing.core.TraverseModeSet;
import org.opentripplanner.routing.edgetype.TripPattern;
import org.opentripplanner.routing.graph.Graph;
import org.opentripplanner.routing.graph.GraphIndex;
import org.opentripplanner.routing.impl.DefaultStreetVertexIndexFactory;
import org.opentripplanner.routing.impl.GraphPathFinder;
import org.opentripplanner.routing.spt.GraphPath;
import org.opentripplanner.standalone.Router;

public class OtpTest {

	public static void main(String[] args) {

		// GTFSファイルの保存フォルダのパス
		String in = "";

		// フォルダ内の全ZIPファイルをGTFSファイルとして読み込む
		List<GtfsBundle> gtfsBundles = null;
		try (final Stream<Path> pathStream = Files.list(Paths.get(in))) {
			gtfsBundles = pathStream.map(Path::toFile).filter(file -> file.getName().toLowerCase().endsWith(".zip"))
					.map(file -> {
						GtfsBundle gtfsBundle = new GtfsBundle(file);
						gtfsBundle.setTransfersTxtDefinesStationPaths(true);
						// 各GTFSファイルに一意のIDをつける必要があるのでファイル名をIDとする
						String id = file.getName().substring(0, file.getName().length() - 4);
						gtfsBundle.setFeedId(new GtfsFeedId.Builder().id(id).build());
						return gtfsBundle;
					}).collect(Collectors.toList());
		} catch (final IOException e) {
			throw new RuntimeException(e);
		}

		// 読み込んだGTFSファイルをGraphオブジェクトに登録する処理
		GtfsModule gtfsModule = new GtfsModule(gtfsBundles);
		Graph graph = new Graph();
		gtfsModule.buildGraph(graph, null);
		graph.summarizeBuilderAnnotations();
		graph.index(new DefaultStreetVertexIndexFactory());
		
		// Graphはシリアライズして保存することも可能
		// graph.save(file);

		// 探索条件の設定
		RoutingRequest routingRequest = new RoutingRequest();
		routingRequest.setNumItineraries(3); // 探索結果の候補数
		// 探索する基準時間を設定
		LocalDateTime ldt = LocalDateTime.parse("1939-08-01T13:00");
		routingRequest.dateTime = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant()).getTime() / 1000;
		routingRequest.setArriveBy(false); // 既定は出発時間指定だが、Trueとすると到着時間を指定できる
		// 出発地と到着地を緯度/経度で指定
		routingRequest.from = new GenericLocation(35.3369, 139.44699); // 辻堂駅
		routingRequest.to = new GenericLocation(35.17096, 136.88232); // 名古屋駅
		routingRequest.ignoreRealtimeUpdates = true; //GTFSRealtime情報を無視
		routingRequest.reverseOptimizing = true; // 探索結果から逆方向に探索し直し、最遅出発時間を探索
		// GTFSデータのみで探索を行う設定
		routingRequest.setModes(new TraverseModeSet(TraverseMode.TRANSIT));
		routingRequest.onlyTransitTrips = true;
		// Graphオブジェクトをセット
		routingRequest.setRoutingContext(graph);

		// 探索処理
		Router router = new Router("OTP", graph);
		List<GraphPath> paths = new GraphPathFinder(router).getPaths(routingRequest);
		TripPlan tripPlan = GraphPathToTripPlanConverter.generatePlan(paths, routingRequest);

		// 探索結果を表示
		tripPlan.itinerary.forEach(p -> {
			// 各経路候補のサマリー情報
			System.out.println(String.format("%d:%02d 出発 - %d:%02d 到着 (%d分)", 
					p.startTime.get(Calendar.HOUR_OF_DAY), p.startTime.get(Calendar.MINUTE), 
					p.endTime.get(Calendar.HOUR_OF_DAY), p.endTime.get(Calendar.MINUTE), 
					(p.duration / 60)));
			p.legs.forEach(l -> {
				//　経路内の各列車の情報
				System.out.println(String.format("> %s %d:%02d 発 - %s %d:%02d 着", 
						l.from.name, l.startTime.get(Calendar.HOUR_OF_DAY), l.startTime.get(Calendar.MINUTE), 
						l.to.name, l.endTime.get(Calendar.HOUR_OF_DAY), l.endTime.get(Calendar.MINUTE)));
			});
		});
	}
}
```

また、LegオブジェクトからはGTFSのshapes.txtに経路の描画情報が格納されている場合は、`l.legGeometry.getPoints()`でGEOエンコードされた文字列として取得できる。