diff --git a/.claude/skills/benchmark-gql/queries.json b/.claude/skills/benchmark-gql/queries.json index ea7d49ce..a0d84c9a 100644 --- a/.claude/skills/benchmark-gql/queries.json +++ b/.claude/skills/benchmark-gql/queries.json @@ -176,7 +176,7 @@ "name": "connectedRoutes", "weight": "heavy", "uses": ["TrainTypeTop", "StationCore", "LineCore", "TrainTypeCore"], - "query": "query Bench_connectedRoutes($from: Int!, $to: Int!) { connectedRoutes(fromStationGroupId: $from, toStationGroupId: $to) { estimatedMinutes transferCount legs { trainType { ...TrainTypeTop } fromStation { ...StationCore } toStation { ...StationCore } } } }", + "query": "query Bench_connectedRoutes($from: Int!, $to: Int!) { connectedRoutes(fromStationGroupId: $from, toStationGroupId: $to) { legs { trainTypes { ...TrainTypeTop } fromStation { ...StationCore } toStation { ...StationCore } } } }", "variables": { "from": 1130205, "to": 1130212 }, "note": "渋谷 → 池袋。乗換を含む経路探索 (RAPTOR、初回は系統網の構築を含む)" }, diff --git a/AGENTS.md b/AGENTS.md index 3bef7e74..84bbbf9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **Endpoint benchmarks** – `make bench` (or `python3 .claude/skills/benchmark-gql/bench.py`) replays every `Query` field against production (`gql.trainlcd.app`, script `stationapi`) and staging (`gql-stg.trainlcd.app`, script `stationapi-stg`) and writes a Markdown report under `benchmarks/`. Both environments embed the same data, so any difference is implementation — which makes this the way to see what a `dev`-to-`master` release will do to performance before it ships. Besides client latency it records the Worker's `cpuTime`, read from `wrangler tail --format json` and matched to each request by `cf-ray`; the tail is filtered on a per-run request header, so production's live traffic does not leak into the sample. Collecting CPU time needs the `workers_tail (read)` scope, and the run sends hundreds of real requests to production — it is not a routine check. Add a case to `.claude/skills/benchmark-gql/queries.json` whenever a `Query` field is added, and never edit an existing case's variables: the reports are meant to stay comparable across runs. ## GraphQL Query Overview -- **Stations** – `station`, `stations`, `stationGroupStations`, `stationsNearby`, `lineStations`, `stationsByName`, `lineGroupStations`, `lineListStations`, `lineGroupListStations`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. `lineStations` resolves the line's local train-type group (rail `kind` 0/1 or a `priority > 0` type); when no such group exists — bus lines only carry `BusRoute` (`kind` 7, `priority` 0) variants — it falls back to the line's plain typeless station list so bus stop listings never return empty. +- **Stations** – `station`, `stations`, `stationGroupStations`, `stationsNearby`, `lineStations`, `stationsByName`, `lineGroupStations`, `lineListStations`, `lineGroupListStations`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. `lineStations` resolves the line's local train-type group (rail `kind` 0/1 or a `priority > 0` type); when no such group exists — bus lines only carry `BusRoute` (`kind` 7, `priority` 0) variants — it falls back to the line's plain typeless station list so bus stop listings never return empty. `stationsByName` with `fromStationGroupId` returns the stations reachable from there: stations sharing a line group with the origin (`line_group_cd` set, `has_train_types` true), same-line stations when either side has no line group, and — for rail — stations reachable by transferring, i.e. those for which `connectedRoutes` with `viaLineId` set to the station's line returns a route (`line_group_cd` empty, `has_train_types` false). The transfer check uses `RouteTopology` (`stationapi/src/domain/route_topology.rs`), a time-free copy of the `connectedRoutes` network built straight from the index without `Station` entities or time estimates (about 20 ms instead of about 190 ms, cached in its own `OnceLock`); `RouteNetwork` holds the same topology, both share `trim_pattern` and `line_group_rows`, and a real-data test asserts the two are equal. The check is a ride-limited BFS over line groups (a few ms) plus, only for destinations that are cut vertices of the station–line-group graph, a check that arrives without stopping over at the destination group — otherwise a branch's junction station (Ishibashi-handai-mae on the Minoo Line) would be listed although reaching it on that branch means riding out and back. - **Lines** – `line`, `lines`, `linesByName`. Results include company data and computed line symbols based on repository helpers. - **Routes** – `routes`, `connectedRoutes`, `estimateArrivalTimes`, `trainRoute`. Paging tokens are currently empty (pagination not implemented). - **`trainRoute`** – Takes the line group's stops from the repository *before* any enrichment, slices them to the requested `fromStationId`–`toStationId` range (reversing when the request runs backwards), and only then attaches lines, companies, station numbers, train types, and nearby bus routes. Enrichment is per-station and independent, so slicing first does not change any segment; enriching the whole line group first made a three-station request cost the same as a 250-station one. Keep the order — the cost of this query must stay proportional to the requested range, not to the line group. @@ -76,7 +76,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **Bus stop translations (readings & English)** – GTFS-JP `translations.txt` layouts differ per feed, so `load_gtfs_translations` resolves columns by header name (Seibu ships 6 columns without `record_sub_id`; Keio and the Tokyu community feeds ship 7) and indexes each `stop_name` translation under both keys it may use: `record_id` (== the stop_id, Seibu — with the "-NN" pole suffix also mapped to the parent stop_id) and `field_value` (== the Japanese stop_name, Keio / Tokyu community, where `record_id` is left empty). `import_gtfs_stops` then looks a stop's translation up by stop_id first, then by name. Keying only by `record_id` (the previous behavior) silently dropped every field_value-keyed feed, leaving `station_name_k` filled with the kanji stop_name and `station_name_r` empty. Readings arriving as half-width katakana (`ニシハチオウジ`, Keio / Tokyu community) are folded to full-width via `romaji::to_fullwidth_katakana()` before storage. - **Bus English-name fallback** – When a feed provides no English (`en`) translation for a stop — e.g. Tokyu Bus ordinary-route JSON, which carries only `dc:title` and `odpt:kana` — `src/domain/romaji.rs::romaji_display_name()` derives a modified-Hepburn romanization (with macrons for long vowels, matching the curated rail style: Tōkyō / Kyōto / Shin-Ōsaka) from the kana reading, and the GTFS reader fills `stop_name_r` with it. The fallback never overwrites a real `en` value, and a reading with no convertible kana stays `NULL` rather than emitting a partial transcription. Because `stop_name_r` is the single upstream source that fans out into the `stations` projection, `search_by_name`, and the romanized bus route/headsign names, this supplements every English-facing surface at once. When projecting into `stations`, `station_name_rn` is filled with the plain-ASCII spelling via `romaji::strip_macrons()` (Tōkyō → Tokyo), mirroring the rail dataset's `_r` (macron) / `_rn` (macron-free) column pair. - **TTS metadata** – `Station`, `StationNested`, `Line`, `LineNested`, `TrainType`, and `TrainTypeNested` expose `name_ipa` / `name_roman_ipa` plus `name_tts_segments` for multi-segment pronunciation output. Use `name_tts_segments` when clients need per-token SSML construction for mixed-language names such as `Kasai-Rinkai Park`. -- **Connected routes** – `connectedRoutes` finds transfer routes automatically, like a journey planner, using a frequency-based RAPTOR search in `stationapi/src/domain/route_search.rs`. Each rail line group is a pattern, station groups are the transfer nodes, and ride times come from `arrival_estimation`; bus lines are excluded. The cost adds a per-boarding wait by `TrainTypeKind` (limited express 15 min, express / high-speed rapid 5 min, others 3 min) and a 3-minute transfer walk — without the wait, infrequent limited expresses would beat the Yamanote Line. Rounds give the time/transfer Pareto set; alternatives come from re-searching with one leg's parallel line groups banned along that leg (at most 8 searches), and are dropped beyond 1.15 × best + 15 min or with two more transfers than the Pareto set. Routes that pass the same station group in two different legs (backtracking to re-board a banned train) are dropped. Results are ranked by cost + 5 min per transfer, capped at 6, and carry `estimatedMinutes` (excluding the first wait) and `transferCount`. The network (every rail line group plus its time estimates, about 190 ms natively) is built lazily into a `OnceLock` by `StationRepository::get_route_network` on the first `connectedRoutes` call, so other queries never pay for it. Each route is a list of `legs` shaped for the app's one-train-at-a-time flow: every leg carries the same `TrainType` that `routeTypes` returns (a real `groupId`, so the client can call `lineGroupStations`) plus its boarding and alighting `Station`, both on the line that leg runs on — so at a transfer the previous leg's alighting station and the next leg's boarding station may be different stations of one station group. Each leg also carries `trainTypes`, every train type usable on that leg: it is exactly `routeTypes(boarding station group, alighting station group, alighting station's line)` (same dedup, same `lines`, same order — the use case calls `get_train_types`), because the search collapses parallel services such as local and rapid into one route and the app needs them to list types and default to the local. `trainType` stays as the search's representative and may be absent from `trainTypes` after that dedup. `viaLineId`, like `routeTypes`, is the line of the tapped search result and keeps only routes whose last leg arrives on that line. `docs/architecture.md` (乗換経路探索) has the details. +- **Connected routes** – `connectedRoutes` finds transfer routes automatically, like a journey planner, using a frequency-based RAPTOR search in `stationapi/src/domain/route_search.rs`. Each rail line group is a pattern, station groups are the transfer nodes, and ride times come from `arrival_estimation`; bus lines are excluded. The cost adds a per-boarding wait by `TrainTypeKind` (limited express 15 min, express / high-speed rapid 5 min, others 3 min) and a 3-minute transfer walk — without the wait, infrequent limited expresses would beat the Yamanote Line. Rounds give the time/transfer Pareto set; alternatives come from re-searching with one leg's parallel line groups banned along that leg (at most 8 searches), and are dropped beyond 1.15 × best + 15 min or with two more transfers than the Pareto set. Alternative routes that stop at the same station group in two different legs (backtracking to re-board a banned train) are dropped; pass-through stations are not counted, and the Pareto routes of the first search are never dropped this way (otherwise a station `stationsByName` reports as reachable could get no route). Results are ranked by cost + 5 min per transfer and capped at 6. The time and transfer count stay internal (the API does not return them). The network (every rail line group plus its time estimates, about 190 ms natively) is built lazily into a `OnceLock` by `StationRepository::get_route_network` on the first `connectedRoutes` call, so other queries never pay for it. Each route is a list of `legs` shaped for the app's one-train-at-a-time flow: every leg carries its boarding and alighting `Station`, both on the line of the line group the search rode — so at a transfer the previous leg's alighting station and the next leg's boarding station may be different stations of one station group — and `trainTypes`, every train type usable on that leg (real `groupId`s, so the client picks one and calls `lineGroupStations`): it is exactly `routeTypes(boarding station group, alighting station group, alighting station's line)` (same dedup, same `lines`, same order — the use case calls `get_train_types`), because the search collapses parallel services such as local and rapid into one route and the app needs them to list types and default to the local. `viaLineId`, like `routeTypes`, is the line of the tapped search result and keeps only routes whose last leg arrives on that line. `estimateArrivalTimes` and `trainRoute` accept `legs: [RouteLegInput!]` (the `groupId` of the train type picked from each leg's `trainTypes`, plus the leg's `fromStation.id` and `toStation.id`) and then return values for the whole transfer route. A leg endpoint missing from the chosen line group is matched by station group (the picked local may stop at another line's station of the same group), preferring an exact `station_cd` and, among same-group candidates, the pair giving the shortest slice (through services list two stations of a junction group). ETA estimates each leg on its own line group only and chains them from the origin, adding the 3-minute walk and the next train type's wait at each transfer (the same allowance the search ranks by), returning one route with an empty `id`; `trainRoute` concatenates each leg's segments (each leg restarts at distance 0). Both slice legs with the same function, taking the shorter arc on loop lines, so their station sequences match. More than `MAX_RIDES` (6) legs — more than `connectedRoutes` ever returns — legs that do not connect, ends that differ from `fromStationId` / `toStationId`, or combining `legs` with `viaLineIds` / `directionId` / `lineGroupId` are errors. `docs/architecture.md` (乗換経路探索) has the details. - Changes to the published contract require coordinated updates to `schema/public.graphql`, the async-graphql types in `src/graphql/`, and, when the shape of a value changes, `stationapi/src/model.rs` and the DTO conversions. ## Version Control (Git) diff --git a/docs/architecture.md b/docs/architecture.md index 875291fd..d69fa657 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -215,17 +215,17 @@ PostgreSQL のクエリは以下のように置き換えています。 アプリは 1 本の列車 (系統) ごとに「種別を選ぶ → `lineGroupStations` で系統 全体の駅を取る → LCD を動かす」流れで動きます。乗換経路もこの流れに乗せられる -よう、経路は区間 (`legs`) の並びで返し、各区間は `routeTypes` と同じ形の -`TrainType` (実在の `groupId`) と乗車駅・降車駅 (`Station`) を持ちます。 -乗降駅にはその系統が走る路線の駅を返すので、乗換駅では前の区間の降車駅と +よう、経路は区間 (`legs`) の並びで返し、各区間はその区間で乗れる種別 +(`trainTypes`、`routeTypes` と同じ形で実在の `groupId`) と乗車駅・降車駅 +(`Station`) を持ちます。乗降駅には探索が乗った系統が走る路線の駅を返すので、乗換駅では前の区間の降車駅と 次の区間の乗車駅が別の駅 (同じ駅グループ) になることがあります (例: 丸ノ内線の赤坂見附 → 半蔵門線の永田町)。 ```graphql connectedRoutes(fromStationGroupId: Int!, toStationGroupId: Int!, viaLineId: Int): [ConnectedRoute!]! -type ConnectedRoute { estimatedMinutes: Float transferCount: Int legs: [RouteLeg!] } -type RouteLeg { trainType: TrainType trainTypes: [TrainType!] fromStation: Station toStation: Station } +type ConnectedRoute { legs: [RouteLeg!] } +type RouteLeg { trainTypes: [TrainType!] fromStation: Station toStation: Station } ``` 探索は停車駅が同じ並行種別 (中央線の快速・通勤快速など) を 1 つの経路に @@ -235,8 +235,8 @@ type RouteLeg { trainType: TrainType trainTypes: [TrainType!] fromStation: Sta 乗れる種別すべてを返します。中身は `routeTypes(乗車駅グループ, 降車駅グループ, 降車駅の路線)` そのもので、停車駅が 同じ種別のまとめ・路線の付与・並び順も `routeTypes` と同じです (同じ関数を -呼んでいます)。`trainType` は探索が選んだ代表の 1 件で、まとめの結果 -`trainTypes` に含まれないことがあります。 +呼んでいます)。探索が選んだ代表の種別、推定所要時間、乗換回数は並べ替えに +使うだけで、API では返しません (アプリが使わないため)。 `viaLineId` は `routeTypes` と同じく検索結果でタップした駅の路線で、目的地に その路線の駅で着く経路 (最後の区間がその路線を走る経路) だけに絞ります。 @@ -269,9 +269,7 @@ type RouteLeg { trainType: TrainType trainTypes: [TrainType!] fromStation: Sta | 乗換の徒歩 | 3 分 | 待ち時間を入れないと、本数の少ない特急が「直通で速い」ことになり、 -東京→渋谷で山手線より成田エクスプレスを勧めてしまいます。返す推定所要時間 -(`ConnectedRoute.estimatedMinutes`) は評価値から最初の列車の待ち時間を除いたもので、 -乗換先の待ち時間は含みます。 +東京→渋谷で山手線より成田エクスプレスを勧めてしまいます。 ### 代替経路と並び順 @@ -286,10 +284,72 @@ type RouteLeg { trainType: TrainType trainTypes: [TrainType!] fromStation: Sta 上回るものを捨てます。乗車回数を増やしても順位の値が良くならないパレート解 (1 分縮めるために乗り換え続ける経路) も捨てます。 -別々の区間で同じ駅グループを通る経路も捨てます。区間を禁止して再探索すると、 -「1 駅戻って同じ列車に乗り直す」逆戻りが代替経路として出てくるためです -(例: 大宮 → 土呂 → 大宮を通過して東京)。1 つの区間の中で同じ駅を通るのは -実在する運行 (大江戸線の都庁前など) なので構いません。 +代替経路では、別々の区間で同じ駅グループに停車する経路も捨てます。区間を +禁止して再探索すると、「1 駅戻って同じ列車に乗り直す」逆戻りが代替経路として +出てくるためです (例: 大宮 → 土呂 → 大宮に停車して東京)。通過した駅は数えません +(急行で通過した駅へ先の駅から戻るのは実際にある乗り方です)。1 つの区間の中で +同じ駅に止まるのは実在する運行 (大江戸線の都庁前など) なので構いません。初回 +探索のパレート解 (最適解) にはこの除外をかけません。かけると、逆戻りしか経路の +無い駅が「行ける駅」(`stationsByName`) なのに 0 件になるためです。 + +### 到着見込みと走行区間 (`estimateArrivalTimes` / `trainRoute`) + +どちらも `legs: [RouteLegInput!]` を受け付け、乗換経路全体を通した値を返します。 +`legs` には `connectedRoutes` の各区間の `trainTypes` から選んだ種別の +`groupId` と、区間の `fromStation.id`・`toStation.id` を渡します。経路 ID は +持たないので、経路はクライアントが区間の並びとして渡します。 + +選んだ種別が区間の乗降駅とは別の路線の駅に止まることがあります (乗降駅は +中央線快速の三鷹だが、選んだ各停は中央・総武線の三鷹に止まる、など)。そこで +系統に無い乗降駅は、同じ駅グループの駅で引き当てます。`station_cd` が一致する +駅があればそれを使い、駅グループの候補が複数あれば区間が最も短くなる組を +選びます (直通系統は接続駅で同じ駅グループの駅を 2 行持つため。宇都宮線の上野と +上野東京ラインの上野など)。 + +```graphql +input RouteLegInput { lineGroupId: Int! fromStationId: Int! toStationId: Int! } +``` + +- `estimateArrivalTimes`: 各区間を指定された系統だけで推定し (両駅に止まる別の + 系統は使わない)、出発駅からの累積でつないだ 1 本の経路を返します (`id` は + 系統をまたぐので空)。乗換駅は前の区間の降車駅と次の区間の乗車駅の 2 行で、 + 乗車駅の行は「徒歩 3 分後に着き、乗換先の種別の待ち時間の後に出る」値です。 + 見込みは `connectedRoutes` の並べ替えと同じ (徒歩 3 分と種別ごとの待ち時間) です。 +- `trainRoute`: 区間ごとの走行区間を順につなげます。区間ごとに別の列車なので、 + 各区間の最初の駅の `distanceFromPrevious` は 0 で、通過駅の有無 (優等種別の + 速度を使うか) も区間ごとに判定します。 + +区間の切り出しは 2 つで同じ関数を通し、環状線では継ぎ目を跨ぐ短い方の弧を取る +ので、両者の駅の並びは一致します (`lineGroupId` 指定の `trainRoute` は従来どおり +格納順で切り出します)。区間がつながっていない (前の区間の降車駅と次の区間の +乗車駅が別の駅グループ)、区間が 6 (`MAX_RIDES`、`connectedRoutes` が返しうる +乗車回数) を超える、端の駅が `fromStationId` / `toStationId` と食い違う、 +`viaLineIds`・`directionId`・`lineGroupId` と併用した、のいずれかはエラーです。 + +### 行き先の検索 (`stationsByName`) + +`stationsByName` に `fromStationGroupId` を指定すると、そこから行ける駅に +絞ります。出発駅と系統を共有する駅 (直通) と、どちらかが系統を持たない同じ +路線の駅に加え、乗り換えればその駅の路線の列車で着ける鉄道駅も返します。 +最後のものは「`connectedRoutes` で `viaLineId` をその駅の路線にすると経路が +出る駅」と一致させてあり、系統を共有しないので `line_group_cd` は空、 +`hasTrainTypes` は偽です (直通の駅と見分けられます)。 + +判定には、`connectedRoutes` と同じ系統から作った**所要時間を持たない網** +(`stationapi/src/domain/route_topology.rs` の `RouteTopology`) を使います。 +要るのは「どの系統がどの駅に止まるか」だけなので、`Station` の組み立ても +所要時間の推定もせず索引から直接作り、組み立ては約 20ms (所要時間つきの網の +約 1/10) です。`RouteNetwork` も内部に同じ網を持ち、系統の整え方 +(`trim_pattern`) と駅の選び方 (`line_group_rows`) を共有しています。両者が +一致することは実データのテストで確かめています。乗車 6 本以内で系統を +幅優先でたどります (1 回数 ms)。ただし探索は目的地の駅グループで途中下車しないので、 +幅優先だけでは「支線の根元の駅に、支線へ一度出て戻って着く」経路を数えて +しまいます (石橋阪大前に箕面線で着く、新函館北斗に函館本線で着く、など。 +実データで 0.3〜0.7% の駅)。これが起きるのは目的地が駅と系統の二部グラフの +関節点のときだけなので、系統網の組み立て時に関節点を求めておき (Tarjan)、 +該当する駅に限って目的地で降りない幅優先で確かめます。実データの 3 つの +出発駅で各 1,500 駅を突き合わせ、探索との食い違いが無いことを確認しています。 +`stationsByName` は 100 件ヒットでも 2〜4ms です (網の組み立て後)。 ### 計算量 diff --git a/schema/public.graphql b/schema/public.graphql index dfee7ab2..472832ba 100644 --- a/schema/public.graphql +++ b/schema/public.graphql @@ -93,13 +93,11 @@ type Query { routes(fromStationGroupId: Int!, toStationGroupId: Int!, viaLineId: Int, pageSize: Int, pageToken: String): RoutePage! routeTypes(fromStationGroupId: Int!, toStationGroupId: Int!, viaLineId: Int, pageSize: Int, pageToken: String): RouteTypePage! connectedRoutes(fromStationGroupId: Int!, toStationGroupId: Int!, viaLineId: Int): [ConnectedRoute!]! - estimateArrivalTimes(fromStationId: Int!, toStationId: Int!, viaLineIds: [Int!], directionId: Int): EstimatedArrivalPage! - trainRoute(fromStationId: Int!, toStationId: Int!, lineGroupId: Int): TrainRouteResponse! + estimateArrivalTimes(fromStationId: Int!, toStationId: Int!, viaLineIds: [Int!], directionId: Int, legs: [RouteLegInput!]): EstimatedArrivalPage! + trainRoute(fromStationId: Int!, toStationId: Int!, lineGroupId: Int, legs: [RouteLegInput!]): TrainRouteResponse! } type ConnectedRoute { - estimatedMinutes: Float - transferCount: Int legs: [RouteLeg!] } @@ -287,8 +285,13 @@ type Route { stops: [StationNested!] } +input RouteLegInput { + lineGroupId: Int! + fromStationId: Int! + toStationId: Int! +} + type RouteLeg { - trainType: TrainType trainTypes: [TrainType!] fromStation: Station toStation: Station diff --git a/src/graphql/query.rs b/src/graphql/query.rs index 6dee0360..50ea09de 100644 --- a/src/graphql/query.rs +++ b/src/graphql/query.rs @@ -6,6 +6,7 @@ use async_graphql::{Context, Object, Result as GqlResult}; use stationapi::domain::entity::gtfs::TransportTypeFilter; +use stationapi::domain::route_search; use stationapi::model; use stationapi::use_case::traits::query::QueryUseCase; @@ -55,6 +56,44 @@ fn to_opt_id(value: Option, name: &str) -> Result, async_graphq value.map(|v| to_id(v, name)).transpose() } +/// 乗換経路の区間の指定を検証して変換する。区間の端は `fromStationId` / +/// `toStationId` と一致しなければならない (食い違うと、どちらが正しいか決められない)。 +fn route_legs( + legs: Vec, + from_station_id: u32, + to_station_id: u32, +) -> Result, async_graphql::Error> { + // connectedRoutes は乗車 MAX_RIDES 本までしか返さない。区間ごとに駅の取得と + // 推定・付帯情報の付与が走るので、それを超える指定は変換する前に断る + if legs.len() > route_search::MAX_RIDES { + return Err(async_graphql::Error::new(format!( + "legs は {} 区間までにしてください", + route_search::MAX_RIDES + ))); + } + let legs: Vec = legs + .into_iter() + .map(|leg| { + Ok(model::RouteLegRequest { + line_group_id: to_id(leg.line_group_id, "legs.lineGroupId")?, + from_station_id: to_id(leg.from_station_id, "legs.fromStationId")?, + to_station_id: to_id(leg.to_station_id, "legs.toStationId")?, + }) + }) + .collect::>()?; + let (Some(first), Some(last)) = (legs.first(), legs.last()) else { + return Err(async_graphql::Error::new( + "legs には 1 つ以上の区間を指定してください", + )); + }; + if first.from_station_id != from_station_id || last.to_station_id != to_station_id { + return Err(async_graphql::Error::new( + "legs の最初の乗車駅と最後の降車駅は fromStationId と toStationId に一致させてください", + )); + } + Ok(legs) +} + fn use_case<'a>(ctx: &Context<'a>) -> &'a Interactor { ctx.data_unchecked::() } @@ -339,7 +378,46 @@ impl QueryRoot { to_station_id: i32, via_line_ids: Option>, direction_id: Option, + legs: Option>, ) -> GqlResult { + // 乗換経路: 区間をつないだ 1 本の経路を、出発駅からの累積で返す。 + // 経路は legs で決まるので、経路を絞る viaLineIds / directionId とは併用しない + if let Some(legs) = legs { + if via_line_ids.is_some_and(|ids| !ids.is_empty()) || direction_id.is_some() { + return Err(async_graphql::Error::new( + "legs を指定するときは viaLineIds と directionId を指定しないでください", + )); + } + let legs = route_legs( + legs, + to_id(from_station_id, "fromStationId")?, + to_id(to_station_id, "toStationId")?, + )?; + let stops = use_case(ctx) + .estimate_connected_route_arrival_times(&legs) + .await?; + return Ok(EstimatedArrivalPage { + routes: Some(vec![EstimatedArrivalRoute { + // 系統をまたぐので 1 つの系統 ID では表せない + id: None, + stops: Some( + stops + .iter() + .map(|stop| EstimatedArrivalStop { + station_id: Some(stop.station_cd), + station_group_id: Some(UInt32(stop.station_g_cd as u32)), + cumulative_minutes: Some(stop.cumulative_minutes), + stops_here: Some(stop.stops_here), + departure_cumulative_minutes: Some( + stop.departure_cumulative_minutes, + ), + }) + .collect(), + ), + }]), + }); + } + let via: Vec = via_line_ids .unwrap_or_default() .into_iter() @@ -392,7 +470,26 @@ impl QueryRoot { from_station_id: i32, to_station_id: i32, line_group_id: Option, + legs: Option>, ) -> GqlResult { + // 乗換経路: 区間ごとの走行区間を順につなげる。系統は区間ごとに決まるので + // lineGroupId とは併用しない + if let Some(legs) = legs { + if line_group_id.is_some() { + return Err(async_graphql::Error::new( + "legs を指定するときは lineGroupId を指定しないでください", + )); + } + let legs = route_legs( + legs, + to_id(from_station_id, "fromStationId")?, + to_id(to_station_id, "toStationId")?, + )?; + let segments = use_case(ctx).get_connected_train_route(&legs).await?; + return Ok(TrainRouteResponse { + segments: Some(segments.into_iter().map(Into::into).collect()), + }); + } let segments = use_case(ctx) .get_train_route( to_id(from_station_id, "fromStationId")?, diff --git a/src/graphql/types.rs b/src/graphql/types.rs index 7f6e1e41..2fc3eb2f 100644 --- a/src/graphql/types.rs +++ b/src/graphql/types.rs @@ -8,7 +8,7 @@ //! use_case の DTO が計算しているので、domain エンティティから直接ではなく //! モデルを経由することでそのロジックをそのまま使える。 -use async_graphql::SimpleObject; +use async_graphql::{InputObject, SimpleObject}; use stationapi::model; use super::enums::*; @@ -312,31 +312,37 @@ impl From for Route { } } -// 乗換経路探索 (connectedRoutes) の経路。各区間の trainType は routeTypes と -// 同じ形で、groupId は実在の系統を指す。クライアントは区間ごとに -// lineGroupStations で系統全体の駅を取れる。 +// 乗換経路探索 (connectedRoutes) の経路。区間ごとに乗れる種別 (routeTypes と同じ形で、 +// groupId は実在の系統) と乗降駅を持つ。クライアントは区間ごとに種別を選び、 +// lineGroupStations で系統全体の駅を取れる #[derive(SimpleObject)] #[graphql(name = "ConnectedRoute")] pub struct ConnectedRoute { - pub estimated_minutes: Option, - pub transfer_count: Option, pub legs: Option>, } impl From for ConnectedRoute { fn from(v: model::ConnectedRoute) -> Self { Self { - estimated_minutes: Some(v.estimated_minutes), - transfer_count: Some(v.transfer_count as i32), legs: Some(v.legs.into_iter().map(Into::into).collect()), } } } +// estimateArrivalTimes / trainRoute に乗換経路を渡すときの 1 区間。connectedRoutes の +// 区間の trainTypes から選んだ種別の groupId と、区間の fromStation.id・toStation.id。 +// 乗降駅は駅グループで系統の中から引き当てるので、どの種別を選んでもよい +#[derive(InputObject)] +#[graphql(name = "RouteLegInput")] +pub struct RouteLegInput { + pub line_group_id: i32, + pub from_station_id: i32, + pub to_station_id: i32, +} + #[derive(SimpleObject)] #[graphql(name = "RouteLeg")] pub struct RouteLeg { - pub train_type: Option, // この区間で乗れる種別すべて。routeTypes(乗車駅グループ, 降車駅グループ, // 降車駅の路線) と同じ結果・同じ並び pub train_types: Option>, @@ -347,7 +353,6 @@ pub struct RouteLeg { impl From for RouteLeg { fn from(v: model::RouteLeg) -> Self { Self { - train_type: Some(v.train_type.into()), train_types: Some(v.train_types.into_iter().map(Into::into).collect()), from_station: Some(v.from_station.into()), to_station: Some(v.to_station.into()), diff --git a/src/repository.rs b/src/repository.rs index 2bdcffd1..eedb3ad4 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -20,6 +20,7 @@ use stationapi::domain::repository::line_repository::LineRepository; use stationapi::domain::repository::station_repository::StationRepository; use stationapi::domain::repository::train_type_repository::TrainTypeRepository; use stationapi::domain::route_search::RouteNetwork; +use stationapi::domain::route_topology::{RouteStop, RouteTopology}; use stationapi::model::StopCondition; use crate::index; @@ -96,28 +97,36 @@ fn apply_first_train_type(station: &mut Station) { } } +/// 系統の停車駅の行を sst.id 昇順で返す。駅・路線・種別のいずれかが引けない行と、 +/// 無効な駅・路線の行は落とす。 +/// +/// 経路探索の網 (`RouteNetwork`) と到達判定の網 (`RouteTopology`) はどちらも +/// この行から作るので、同じ系統からは同じ網になる。 +fn line_group_rows( + group_id: i32, +) -> impl Iterator< + Item = ( + &'static index::SstRecord, + &'static index::StationRecord, + &'static Line, + &'static index::TypeRecord, + ), +> { + index::sst_by_group(group_id).filter_map(|sst| { + let record = index::station_by_cd(sst.station_cd).filter(|r| r.e_status == 0)?; + let line = index::line_by_cd(record.line_cd).filter(|l| l.e_status == 0)?; + // 種別が引けない系統は落とす + let ty = index::type_by_cd(sst.type_cd)?; + Some((sst, record, line, ty)) + }) +} + /// 指定した系統の停車駅を返す。並びは指定された系統の順、各系統内は sst.id 昇順。 /// 駅・路線・種別のいずれかが引けない行は落とす。 fn stations_of_line_groups(group_ids: &[u32]) -> Vec { let mut out = Vec::new(); for &group_id in group_ids { - for sst in index::sst_by_group(group_id as i32) { - let Some(record) = index::station_by_cd(sst.station_cd) else { - continue; - }; - if record.e_status != 0 { - continue; - } - let Some(line) = index::line_by_cd(record.line_cd) else { - continue; - }; - if line.e_status != 0 { - continue; - } - // 種別が引けない系統は落とす - let Some(ty) = index::type_by_cd(sst.type_cd) else { - continue; - }; + for (sst, record, line, ty) in line_group_rows(group_id as i32) { let mut station = record.to_entity(Some(line)); apply_train_type(&mut station, sst, ty); out.push(station); @@ -126,28 +135,59 @@ fn stations_of_line_groups(group_ids: &[u32]) -> Vec { out } -/// 乗換経路探索用の系統網。全系統の駅と所要時間の推定から組み立てるので、 -/// 最初に `connectedRoutes` が呼ばれたときに一度だけ作り、isolate の寿命の間 -/// 使い回す。他のクエリしか来ない isolate では組み立てない。 -static ROUTE_NETWORK: OnceLock> = OnceLock::new(); - -/// 有効な鉄道路線の系統だけで系統網を組み立てる。バスは探索の対象にしない。 +/// 鉄道の系統 (line_group_cd の昇順)。バスは探索の対象にしない。 /// -/// 系統の種別判定は先頭の駅で行い、バスの系統では `Station` を組み立てない -/// (GTFS 由来のバス系統は数が多く、作ってから捨てると起動が遅くなる)。 -fn build_route_network() -> RouteNetwork { - let rail_line_groups = index::line_group_cds().into_iter().filter(|&group| { +/// 系統の種別判定は先頭の駅で行う (GTFS 由来のバス系統は数が多いので、行を +/// 作ってから捨てると組み立てが遅くなる)。 +fn rail_line_group_cds() -> impl Iterator { + index::line_group_cds().into_iter().filter(|&group| { index::sst_by_group(group) .find_map(|sst| index::station_by_cd(sst.station_cd)) .and_then(|record| index::line_by_cd(record.line_cd)) .is_some_and(|line| line.transport_type == TransportType::Rail) - }); + }) +} + +/// 乗換経路探索 (`connectedRoutes`) 用の系統網。全系統の駅と所要時間の推定から +/// 組み立てるので、最初に `connectedRoutes` が呼ばれたときに一度だけ作り、 +/// isolate の寿命の間使い回す。 +static ROUTE_NETWORK: OnceLock> = OnceLock::new(); + +fn route_network() -> &'static Arc { + ROUTE_NETWORK.get_or_init(|| Arc::new(build_route_network())) +} + +fn build_route_network() -> RouteNetwork { RouteNetwork::build( - rail_line_groups.map(|group| stations_of_line_groups(&[group as u32])), + rail_line_group_cds().map(|group| stations_of_line_groups(&[group as u32])), &EstimationParams::default(), ) } +/// 行き先の検索 (`stationsByName`) で乗換の到達判定に使う、所要時間を持たない +/// 系統網。`Station` も所要時間の推定も要らないので、`ROUTE_NETWORK` よりずっと +/// 速く組み立てられる。`ROUTE_NETWORK` の中の網と同じものになる (テストで確認)。 +static ROUTE_TOPOLOGY: OnceLock = OnceLock::new(); + +fn route_topology() -> &'static RouteTopology { + ROUTE_TOPOLOGY.get_or_init(build_route_topology) +} + +fn build_route_topology() -> RouteTopology { + RouteTopology::build(rail_line_group_cds().map(|group| { + line_group_rows(group) + .map(|(sst, record, _, _)| RouteStop { + station_cd: record.station_cd, + station_group_id: record.station_g_cd as u32, + line_cd: record.line_cd, + // RouteNetwork は pass と stop_condition で判定する。apply_train_type は + // pass == 1 のときだけ stop_condition を Not にするので同じ結果になる + stoppable: sst.pass != Some(1), + }) + .collect() + })) +} + // ---------------------------------------------------------------- 駅 #[derive(Clone, Default)] @@ -156,9 +196,7 @@ pub struct MemStationRepository; #[async_trait] impl StationRepository for MemStationRepository { async fn get_route_network(&self) -> Result, DomainError> { - Ok(Arc::clone( - ROUTE_NETWORK.get_or_init(|| Arc::new(build_route_network())), - )) + Ok(Arc::clone(route_network())) } async fn get_by_coordinates( @@ -186,10 +224,12 @@ impl StationRepository for MemStationRepository { } /// 名前の部分一致に加えて、`from_station_group_id` が指定された場合は - /// 「その駅から乗り換えなしで行けるか」で絞り込む。条件は次のどちらか。 + /// 「その駅から行けるか」で絞り込む。条件は次のいずれか。 /// - /// - 出発駅と同じ系統に、通過ではない停車として含まれる - /// - 出発駅か目的駅のどちらかが系統を持たず、かつ同じ路線にある + /// - 出発駅と同じ系統に、通過ではない停車として含まれる (分岐 A) + /// - 出発駅か目的駅のどちらかが系統を持たず、かつ同じ路線にある (分岐 B) + /// - 鉄道で、乗り換えればその駅の路線の列車で着ける (分岐 C。 + /// `connectedRoutes(viaLineId = その駅の路線)` で経路が出る駅) /// /// `from_station_group_id` が無ければ絞り込みは掛からない。 /// 件数の上限は絞り込みの後に効くため、切るのは最後。 @@ -234,6 +274,11 @@ impl StationRepository for MemStationRepository { } } + // 乗換で行ける駅の判定。connectedRoutes と同じ系統から作った、所要時間を + // 持たない網を使う。乗換が要る駅が出たときに一度だけ作る。網は鉄道だけ + let rail_wanted = want.is_none_or(|t| t == TransportType::Rail as i32); + let mut reachability = None; + let mut out = Vec::new(); for record in hits { let mut dst_has_sst = false; @@ -251,8 +296,22 @@ impl StationRepository for MemStationRepository { } else { from_line_cds.contains(&record.line_cd) }; + // 分岐 C: 乗り換えれば、この駅にこの路線の列車で着ける + // (connectedRoutes(viaLineId = この駅の路線) で経路が出る)。 + // 共有する系統は無いので line_group_cd は空、has_train_types は false if shared_group.is_none() && !same_line { - continue; + if !rail_wanted { + continue; + } + let reachability = + reachability.get_or_insert_with(|| route_topology().reachability(group_id)); + if !reachability.can_arrive( + record.station_cd, + record.station_g_cd as u32, + record.line_cd, + ) { + continue; + } } let mut station = record.to_entity(index::line_by_cd(record.line_cd)); @@ -1147,12 +1206,244 @@ impl TrainTypeRepository for MemTrainTypeRepository { #[cfg(test)] mod tests { use super::*; + use stationapi::domain::route_search::{self, Journey}; + use stationapi::model; const TOKYO: u32 = 1130101; const SHIBUYA: u32 = 1130205; const MITAKA: u32 = 1131105; const NAKA_MEGURO: u32 = 2600103; + /// repository の実装は await しない (索引を引くだけ) ので、1 回 poll すれば終わる + fn block_on(future: F) -> F::Output { + let mut context = std::task::Context::from_waker(std::task::Waker::noop()); + match std::pin::pin!(future).poll(&mut context) { + std::task::Poll::Ready(value) => value, + std::task::Poll::Pending => panic!("repository futures complete without waiting"), + } + } + + fn names_by_line(from: u32, name: &str) -> Vec<(String, i32, bool)> { + block_on(MemStationRepository.get_by_name(name.to_string(), Some(100), Some(from), None)) + .unwrap() + .into_iter() + .map(|s| (s.station_name, s.line_cd, s.has_train_types)) + .collect() + } + + /// 探索結果の経路を estimateArrivalTimes / trainRoute の legs にする + /// (connectedRoutes が区間ごとに返す乗降駅と、探索が選んだ系統) + fn journey_legs(journey: &Journey) -> Vec { + journey + .legs + .iter() + .map(|leg| model::RouteLegRequest { + line_group_id: leg.line_group_id, + from_station_id: leg.station_cds[0] as u32, + to_station_id: *leg.station_cds.last().unwrap() as u32, + }) + .collect() + } + + fn station_ids_of(eta: &[stationapi::domain::arrival_estimation::EstimatedStop]) -> Vec { + eta.iter().map(|stop| stop.station_cd).collect() + } + + #[test] + fn connected_route_eta_and_train_route_follow_the_legs() { + use stationapi::use_case::traits::query::QueryUseCase; + let interactor = crate::interactor(); + // 大宮 → 新大阪 (はやぶさ → 東京 → のぞみ など) と、山手線の継ぎ目を + // 跨ぎうる東京 → 渋谷 + for (from, to) in [(1131906, 1160213), (TOKYO, SHIBUYA)] { + let journeys = route_network().search(from, to, None); + assert!(!journeys.is_empty()); + for journey in &journeys { + let legs = journey_legs(journey); + let eta = + block_on(interactor.estimate_connected_route_arrival_times(&legs)).unwrap(); + let train_route = block_on(interactor.get_connected_train_route(&legs)).unwrap(); + + // 同じ区間を同じ弧で切り出す + let eta_ids = station_ids_of(&eta); + let train_route_ids: Vec = train_route + .iter() + .map(|segment| segment.station.as_ref().unwrap().id as i32) + .collect(); + assert_eq!(eta_ids, train_route_ids); + // 探索の区間と同じ駅を通る + let journey_ids: Vec = journey + .legs + .iter() + .flat_map(|leg| leg.station_cds.iter().copied()) + .collect(); + assert_eq!(eta_ids, journey_ids); + // 累積は減らず、最後は探索の所要時間と (ほぼ) 一致する + assert!(eta.windows(2).all(|pair| { + pair[1].cumulative_minutes >= pair[0].departure_cumulative_minutes - 1e-9 + })); + let last = eta.last().unwrap().cumulative_minutes; + let expected = f64::from(journey.total_seconds) / 60.0; + assert!( + (last - expected).abs() < 1.0, + "eta {last} vs search {expected}" + ); + // 乗換では徒歩の後に乗換先の列車を待ち、走行区間は距離 0 から始まる。 + // 乗車駅の行は、前の区間の降車駅の行のすぐ後 + let mut board = 0; + for leg in &journey.legs[..journey.legs.len() - 1] { + board += leg.station_cds.len(); + assert!( + eta[board].departure_cumulative_minutes > eta[board].cumulative_minutes + ); + assert!(train_route[board].distance_from_previous == 0.0); + } + } + } + } + + #[test] + fn connected_route_legs_accept_any_train_type_of_the_leg() { + use stationapi::use_case::traits::query::QueryUseCase; + let interactor = crate::interactor(); + // 区間の乗降駅は中央線 (快速) の三鷹 (1131220) と新宿 (1131211) だが、 + // 中央・総武線の各停 (系統 585) を選んでも、同じ駅グループにある各停の駅で + // 切り出す + let legs = [model::RouteLegRequest { + line_group_id: 585, + from_station_id: 1131220, + to_station_id: 1131211, + }]; + let eta = block_on(interactor.estimate_connected_route_arrival_times(&legs)).unwrap(); + let group_of = |station_cd: i32| index::station_by_cd(station_cd).unwrap().station_g_cd; + let (first, last) = (eta[0].station_cd, eta.last().unwrap().station_cd); + assert_eq!((group_of(first), group_of(last)), (MITAKA as i32, 1130208)); + assert!( + first != 1131220 && last != 1131211, + "the local's own stations" + ); + let train_route = block_on(interactor.get_connected_train_route(&legs)).unwrap(); + assert_eq!(train_route.len(), eta.len()); + + // connectedRoutes の区間の trainTypes は、どれを選んでも区間の乗降駅で使える + let routes = block_on(interactor.get_connected_routes(MITAKA, NAKA_MEGURO, None)).unwrap(); + for route in &routes { + let choices = route + .legs + .iter() + .map(|leg| leg.train_types.len()) + .max() + .unwrap(); + for choice in 0..choices { + let legs: Vec = route + .legs + .iter() + .map(|leg| model::RouteLegRequest { + line_group_id: leg.train_types[choice.min(leg.train_types.len() - 1)] + .group_id, + from_station_id: leg.from_station.id, + to_station_id: leg.to_station.id, + }) + .collect(); + let eta = + block_on(interactor.estimate_connected_route_arrival_times(&legs)).unwrap(); + let train_route = block_on(interactor.get_connected_train_route(&legs)).unwrap(); + assert_eq!(eta.len(), train_route.len()); + } + } + } + + #[test] + fn connected_route_rejects_legs_that_do_not_connect() { + use stationapi::use_case::traits::query::QueryUseCase; + let interactor = crate::interactor(); + let routes = block_on(interactor.get_connected_routes(MITAKA, NAKA_MEGURO, None)).unwrap(); + let mut legs: Vec = routes[0] + .legs + .iter() + .map(|leg| model::RouteLegRequest { + line_group_id: leg.train_types[0].group_id, + from_station_id: leg.from_station.id, + to_station_id: leg.to_station.id, + }) + .collect(); + assert!(legs.len() > 1); + // 2 区間目を飛ばすと、1 区間目の降車駅と 3 区間目の乗車駅がつながらない + legs.remove(1); + let error = block_on(interactor.estimate_connected_route_arrival_times(&legs)) + .unwrap_err() + .to_string(); + assert!(error.contains("区間がつながっていません"), "{error}"); + assert!(block_on(interactor.get_connected_train_route(&legs)).is_err()); + assert!(block_on(interactor.get_connected_train_route(&[])).is_err()); + + // connectedRoutes が返しうる乗車回数 (MAX_RIDES) を超える区間は断る。 + // つながった区間 (三鷹と新宿を中央線快速で往復) でも受け付けない + let back_and_forth: Vec = (0..=route_search::MAX_RIDES) + .map(|index| { + let (from, to) = if index % 2 == 0 { + (1131220, 1131211) + } else { + (1131211, 1131220) + }; + model::RouteLegRequest { + line_group_id: 20, + from_station_id: from, + to_station_id: to, + } + }) + .collect(); + let error = block_on(interactor.estimate_connected_route_arrival_times(&back_and_forth)) + .unwrap_err() + .to_string(); + assert!(error.contains("区間までにしてください"), "{error}"); + assert!(block_on(interactor.get_connected_train_route(&back_and_forth)).is_err()); + // 上限ちょうどは受け付ける + let at_limit = &back_and_forth[..route_search::MAX_RIDES]; + assert!(block_on(interactor.get_connected_train_route(at_limit)).is_ok()); + } + + #[test] + fn route_topology_matches_the_topology_inside_the_route_network() { + // 行き先の検索は軽い網、connectedRoutes は所要時間つきの網を使う。 + // 両者がずれると「行ける」と返した駅で経路が出なくなる + assert_eq!(route_topology(), route_network().topology()); + } + + #[test] + fn stations_by_name_includes_stations_reached_by_transfer() { + // 三鷹から中目黒へは直通の系統が無いが、乗り換えれば東横線でも日比谷線でも着く + let found = names_by_line(MITAKA, "中目黒"); + assert!(found.contains(&("中目黒".to_string(), 26001, false))); + assert!(found.contains(&("中目黒".to_string(), 28003, false))); + + // 返した駅には、その路線を viaLineId にした connectedRoutes で経路がある + let network = route_network(); + for (_, line_cd, _) in &found { + assert!(!network + .search(MITAKA, NAKA_MEGURO, Some(*line_cd)) + .is_empty()); + } + } + + #[test] + fn stations_by_name_skips_a_branch_junction_reached_only_by_backtracking() { + // 石橋阪大前は宝塚線 (34002) と、そこから出る箕面線 (34007) の駅。 + // 箕面線の石橋阪大前に箕面線で着くには、一度箕面線へ出て戻るしかない + let found = names_by_line(MITAKA, "石橋阪大前"); + let lines: Vec = found.iter().map(|(_, line_cd, _)| *line_cd).collect(); + assert_eq!(lines, vec![34002]); + } + + #[test] + fn stations_by_name_keeps_direct_stations_marked_as_sharing_a_line_group() { + // 東京から品川は山手線などで直通なので、共有する系統が付く + let found = names_by_line(TOKYO, "品川"); + assert!(found + .iter() + .any(|(name, _, has_train_types)| name == "品川" && *has_train_types)); + } + #[test] fn route_network_finds_direct_and_transfer_routes_in_real_data() { let network = build_route_network(); diff --git a/stationapi/src/domain.rs b/stationapi/src/domain.rs index 6c1922fc..25ffc7f9 100644 --- a/stationapi/src/domain.rs +++ b/stationapi/src/domain.rs @@ -6,5 +6,6 @@ pub mod normalize; pub mod repository; pub mod romaji; pub mod route_search; +pub mod route_topology; pub mod segment_speed_table; pub mod speed_table; diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index 48b7b6af..818c19fb 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -46,6 +46,12 @@ pub trait StationRepository: Send + Sync + 'static { limit: Option, transport_type: Option, ) -> Result, DomainError>; + /// 駅名で探す。`from_station_group_id` を指定すると、そこから行ける駅に絞る。 + /// 行ける駅は、出発駅と系統を共有する駅 (`line_group_cd` にその系統が入り + /// `has_train_types` が真)、どちらかが系統を持たない同じ路線の駅、および + /// 乗り換えればその駅の路線の列車で着ける駅 (`connectedRoutes` で + /// `viaLineId` をその駅の路線にすると経路が出る駅。系統を共有しないので + /// `line_group_cd` は空、`has_train_types` は偽)。 async fn get_by_name( &self, station_name: String, diff --git a/stationapi/src/domain/route_search.rs b/stationapi/src/domain/route_search.rs index b1169b70..5d294715 100644 --- a/stationapi/src/domain/route_search.rs +++ b/stationapi/src/domain/route_search.rs @@ -30,6 +30,7 @@ use crate::domain::arrival_estimation::{ estimate_arrival_minutes_calibrated, is_circular_route, EstimationParams, }; use crate::domain::entity::station::Station; +use crate::domain::route_topology::{trim_pattern, RouteStop, RouteTopology}; /// 乗換 1 回あたりの乗換通路の徒歩(秒)。 pub const TRANSFER_WALK_SECONDS: i32 = 3 * 60; @@ -144,6 +145,8 @@ pub struct RouteNetwork { node_groups: Vec, /// 節点 -> (パターン, 一周目の位置)。乗降できる位置だけ。 stop_patterns: Vec>, + /// 同じ系統から作った、所要時間を持たない網。到達判定はこちらで行う。 + topology: RouteTopology, } impl RouteNetwork { @@ -160,9 +163,15 @@ impl RouteNetwork { for stations in line_groups { network.add_pattern(stations, params); } + network.topology.finish(); network } + /// 同じ系統から作った、所要時間を持たない網 (到達判定用)。 + pub fn topology(&self) -> &RouteTopology { + &self.topology + } + pub fn pattern_count(&self) -> usize { self.patterns.len() } @@ -185,18 +194,24 @@ impl RouteNetwork { let Some(line_group_id) = stations.first().and_then(|s| s.line_group_cd) else { return; }; - // 先頭駅が末尾にも重複格納された「閉じた」環状データは、重複終端を - // 除いてから環状判定する(estimate_route_arrival_times と同じ扱い)。 - if stations.len() > 1 && stations[0].station_cd == stations[stations.len() - 1].station_cd { - stations.pop(); - } - let stoppable: Vec = stations - .iter() - .map(|s| s.pass != Some(1) && s.stop_condition != crate::model::StopCondition::Not) - .collect(); - if stoppable.iter().filter(|&&s| s).count() < 2 { + let is_stoppable = + |s: &Station| s.pass != Some(1) && s.stop_condition != crate::model::StopCondition::Not; + // 系統の整え方は到達判定の網と共有する (同じ系統から同じ網を作るため) + if !trim_pattern(&mut stations, |s| s.station_cd, is_stoppable) { return; } + let stoppable: Vec = stations.iter().map(is_stoppable).collect(); + self.topology.add_pattern( + stations + .iter() + .map(|s| RouteStop { + station_cd: s.station_cd, + station_group_id: s.station_g_cd as u32, + line_cd: s.line_cd, + stoppable: is_stoppable(s), + }) + .collect(), + ); let refs: Vec<&Station> = stations.iter().collect(); let circular = is_circular_route(&refs); @@ -275,8 +290,9 @@ impl RouteNetwork { node: target as usize, line_cd: via_line_id, }; - let mut pareto = self.raptor(origin, arrival, &HashSet::new(), UNREACHED); - pareto.retain(|(_, legs)| !self.revisits_station_group(legs)); + // パレート解は最適解なので逆戻りの除外をかけない。かけると、逆戻りしか + // 経路が無い駅が「行ける駅」(reachable_station_cds) なのに 0 件になる + let pareto = self.raptor(origin, arrival, &HashSet::new(), UNREACHED); if pareto.is_empty() { return Vec::new(); } @@ -549,12 +565,13 @@ impl RouteNetwork { } } - /// 別々の区間で同じ駅グループを通るか。乗換駅 (前の区間の降車駅 = 次の区間の - /// 乗車駅) は除く。 + /// 別々の区間で同じ駅グループに停車するか。乗換駅 (前の区間の降車駅 = 次の + /// 区間の乗車駅) は除く。 /// /// 区間を禁止して再探索すると、「1 駅戻って同じ列車に乗り直す」逆戻りの経路が - /// 代替経路として出てくるので捨てる。1 つの区間の中で同じ駅を通るのは - /// 実在する運行 (大江戸線の都庁前など) なので構わない。 + /// 代替経路として出てくるので捨てる。通過した駅へ戻るのは (急行で先の駅まで + /// 行って戻るなど) 実際にある乗り方なので数えない。1 つの区間の中で同じ駅に + /// 止まるのは実在する運行 (大江戸線の都庁前など) なので構わない。 fn revisits_station_group(&self, legs: &[LegRef]) -> bool { let mut visited: HashSet = HashSet::new(); for leg in legs { @@ -564,7 +581,9 @@ impl RouteNetwork { let board_node = pattern.nodes[board % len]; let mut leg_nodes: HashSet = HashSet::new(); for q in board.min(alight)..=board.max(alight) { - leg_nodes.insert(pattern.nodes[q % len]); + if pattern.stoppable[q % len] { + leg_nodes.insert(pattern.nodes[q % len]); + } } if leg_nodes .iter() @@ -982,6 +1001,185 @@ mod tests { assert_eq!(line_groups(&journeys[0]), vec![100, 200]); } + #[test] + fn reachable_stations_follow_transfers_and_skip_passed_stations() { + let mut express = straight(300, &[(3, 2.0, 0.0), (7, 3.0, 0.0), (8, 4.0, 0.0)]); + express[1].pass = Some(1); + let mut line_groups = vec![ + straight(100, &[(1, 0.0, 0.0), (2, 1.0, 0.0), (3, 2.0, 0.0)]), + straight(200, &[(3, 2.0, 0.0), (5, 2.0, 1.0)]), + express, + // 1 からつながっていない系統 + straight(400, &[(9, 9.0, 9.0), (10, 9.5, 9.0)]), + ]; + // 7 は急行 (300) が通過するが、別の路線 (500) の駅としてなら止まる + line_groups.push(straight(500, &[(8, 4.0, 0.0), (7, 3.0, 0.0)])); + let network = RouteNetwork::build(line_groups, &EstimationParams::default()); + + let reachable = network.topology().reachable_station_cds(1); + // straight() の station_cd は line_group * 100 + 位置 + for station_cd in [ + 10000, 10001, 10002, 20000, 20001, 30000, 30002, 50000, 50001, + ] { + assert!(reachable.contains(&station_cd), "{station_cd} is reachable"); + } + assert!( + !reachable.contains(&30001), + "the express passes 7, so 7 on line 300 is not a place to get off" + ); + assert!(!reachable.contains(&40000) && !reachable.contains(&40001)); + assert!(network.topology().reachable_station_cds(99).is_empty()); + + // 到達できると判定した駅は、その路線を via にした探索で経路が出る + assert!(!network.search(1, 7, Some(500)).is_empty()); + assert!(network.search(1, 7, Some(300)).is_empty()); + } + + #[test] + fn cannot_arrive_at_a_junction_on_the_branch_that_starts_there() { + // 本線 (100) 1 - 2 - 3 と、3 から出る支線 (200) 3 - 4 (石橋阪大前と箕面線)。 + // 支線の 3 に支線の列車で着くには、3 を通って 4 へ出てから戻るしかない + let network = RouteNetwork::build( + [ + straight(100, &[(1, 0.0, 0.0), (2, 1.0, 0.0), (3, 2.0, 0.0)]), + straight(200, &[(3, 2.0, 0.0), (4, 2.0, 1.0)]), + ], + &EstimationParams::default(), + ); + let reachability = network.topology().reachability(1); + // straight() の station_cd は line_group * 100 + 位置、line_cd は line_group + assert!(reachability.can_arrive(10002, 3, 100)); + assert!(reachability.can_arrive(20001, 4, 200)); + assert!( + !reachability.can_arrive(20000, 3, 200), + "the branch's junction station is not reached by the branch itself" + ); + assert!(network.search(1, 3, Some(200)).is_empty()); + assert!( + !reachability.can_arrive(99999, 3, 100), + "unknown stations are unreachable" + ); + } + + #[test] + fn cannot_arrive_at_the_origin_station_group() { + // 2 から同じ系統に乗り直した分として、出発駅 1 の駅も到達集合に入る。 + // 1 は関節点でもないが、search は出発駅と同じ駅グループへは 0 件を返す + let network = RouteNetwork::build( + [straight( + 100, + &[(1, 0.0, 0.0), (2, 1.0, 0.0), (3, 2.0, 0.0)], + )], + &EstimationParams::default(), + ); + let topology = network.topology(); + assert!(topology.reachable_station_cds(1).contains(&10000)); + assert!(!topology.reachability(1).can_arrive(10000, 1, 100)); + assert!(network.search(1, 1, Some(100)).is_empty()); + } + + #[test] + fn reachability_matches_search_on_a_ring_with_branches() { + // 環状 (100) に支線 (200, 300) が付き、支線同士は 5 で接する。どの駅も + // 「着けると判定した駅には経路がある」ことを総当たりで確かめる + let ring: Vec<(i32, f64, f64)> = (0..12) + .map(|i| { + let angle = i as f64 / 12.0 * std::f64::consts::TAU; + (i + 1, angle.cos() * 3.0, angle.sin() * 3.0) + }) + .collect(); + let line_groups = vec![ + straight(100, &ring), + straight(200, &[(3, 1.5, 2.6), (20, 3.0, 5.0), (5, -1.5, 2.6)]), + straight(300, &[(20, 3.0, 5.0), (21, 4.0, 6.0)]), + straight(400, &[(9, 0.0, -3.0), (30, 0.0, -5.0)]), + ]; + let stations: Vec<(i32, u32, i32)> = line_groups + .iter() + .flatten() + .map(|s| (s.station_cd, s.station_g_cd as u32, s.line_cd)) + .collect(); + let network = RouteNetwork::build(line_groups, &EstimationParams::default()); + for origin in [1, 20, 21, 30] { + let reachability = network.topology().reachability(origin); + for &(station_cd, group, line_cd) in &stations { + if group == origin { + continue; + } + assert_eq!( + reachability.can_arrive(station_cd, group, line_cd), + !network.search(origin, group, Some(line_cd)).is_empty(), + "origin {origin}, station {station_cd}" + ); + } + } + } + + #[test] + fn topology_built_from_route_stops_matches_the_one_inside_the_network() { + // 閉じた環状 (先頭駅を末尾にも持つ)、通過駅、停車駅が 1 つしかない系統を含む + let mut closed_ring = straight( + 100, + &[(1, 0.0, 0.0), (2, 1.0, 0.0), (3, 1.0, 1.0), (4, 0.0, 1.0)], + ); + let mut closing = closed_ring[0].clone(); + closing.sst_id = Some(10099); + closed_ring.push(closing); + let mut express = straight(200, &[(2, 1.0, 0.0), (5, 2.0, 0.0), (6, 3.0, 0.0)]); + express[1].pass = Some(1); + let mut single_stop = straight(300, &[(6, 3.0, 0.0), (7, 4.0, 0.0)]); + single_stop[1].pass = Some(1); + let line_groups = vec![closed_ring, express, single_stop]; + + let stops: Vec> = line_groups + .iter() + .map(|stations| { + stations + .iter() + .map(|s| RouteStop { + station_cd: s.station_cd, + station_group_id: s.station_g_cd as u32, + line_cd: s.line_cd, + stoppable: s.pass != Some(1), + }) + .collect() + }) + .collect(); + let network = RouteNetwork::build(line_groups, &EstimationParams::default()); + let topology = RouteTopology::build(stops); + assert_eq!(&topology, network.topology()); + assert_eq!( + topology.pattern_count(), + 2, + "the single-stop line group is dropped" + ); + } + + #[test] + fn reachable_stations_stop_after_the_ride_limit() { + // 0 -> 1 -> ... -> 7 を 1 駅ずつ別の系統でつなぐ。乗車 6 本で 6 まで + let line_groups: Vec> = (0..7) + .map(|i| { + straight( + 100 + i, + &[(i, f64::from(i), 0.0), (i + 1, f64::from(i + 1), 0.0)], + ) + }) + .collect(); + let network = RouteNetwork::build(line_groups, &EstimationParams::default()); + let reachable = network.topology().reachable_station_cds(0); + assert!( + reachable.contains(&(105 * 100 + 1)), + "group 6 by the sixth ride" + ); + assert!( + !reachable.contains(&(106 * 100 + 1)), + "group 7 needs a seventh ride" + ); + assert!(network.search(0, 6, None).len() == 1); + assert!(network.search(0, 7, None).is_empty()); + } + #[test] fn collapses_parallel_train_types_into_one_route() { // 同じ停車駅の系統が 2 つ (種別違い) あっても同じ経路は 1 件にまとめる。 diff --git a/stationapi/src/domain/route_topology.rs b/stationapi/src/domain/route_topology.rs new file mode 100644 index 00000000..7eb558c0 --- /dev/null +++ b/stationapi/src/domain/route_topology.rs @@ -0,0 +1,350 @@ +//! 乗換経路の到達判定に使う、所要時間を持たない系統網。 +//! +//! [`crate::domain::route_search::RouteNetwork`] は所要時間の推定まで持つので、 +//! 組み立てに全駅の `Station` と推定が要る (ネイティブで約 190ms)。行き先の +//! 検索 (`stationsByName`) で要るのは「どの系統がどの駅に止まるか」だけなので、 +//! それだけを持つこの網を別に作る。`RouteNetwork` も内部に同じ網を持ち、 +//! 系統の整え方 ([`trim_pattern`]) を共有するので、同じ系統から作れば同じ網になる。 +//! +//! IO を持たない純粋ロジック。 + +use std::collections::{HashMap, HashSet}; + +use crate::domain::route_search::MAX_RIDES; + +/// 系統の 1 駅。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RouteStop { + pub station_cd: i32, + pub station_group_id: u32, + pub line_cd: i32, + /// 乗降できる (通過ではない) か。 + pub stoppable: bool, +} + +/// 系統の駅列を網に載せる形に整える。載せないなら偽を返す。 +/// +/// - 先頭駅が末尾にも重複格納された「閉じた」環状データは重複終端を除く +/// (estimate_route_arrival_times と同じ扱い) +/// - 乗降できる駅が 2 つ未満の系統は載せない +pub(crate) fn trim_pattern( + stops: &mut Vec, + station_cd: impl Fn(&T) -> i32, + stoppable: impl Fn(&T) -> bool, +) -> bool { + if stops.len() > 1 && station_cd(&stops[0]) == station_cd(&stops[stops.len() - 1]) { + stops.pop(); + } + stops.iter().filter(|stop| stoppable(stop)).count() >= 2 +} + +#[derive(Debug, PartialEq, Eq)] +struct TopologyPattern { + /// 位置ごとの節点。環状でも一周ぶん。 + nodes: Vec, + station_cds: Vec, + line_cds: Vec, + stoppable: Vec, +} + +impl TopologyPattern { + fn len(&self) -> usize { + self.nodes.len() + } +} + +/// 所要時間を持たない系統網。 +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RouteTopology { + patterns: Vec, + node_by_group: HashMap, + /// 節点 -> (パターン, 位置)。乗降できる位置だけ。 + stop_patterns: Vec>, + /// 節点が関節点か (取り除くと網が分かれるか)。[`Reachability::can_arrive`] が + /// 厳密な判定を要る駅を見分けるのに使う。 + cut_nodes: Vec, +} + +/// ある出発駅からの到達判定。[`RouteTopology::reachability`] で作る。 +pub struct Reachability<'a> { + topology: &'a RouteTopology, + origin: Option, + station_cds: HashSet, +} + +impl Reachability<'_> { + /// 駅 (`station_cd`、駅グループ `station_group_id`、路線 `line_cd`) に、その + /// 路線の列車で乗車 [`MAX_RIDES`] 本以内に着けるか。 + /// + /// 真なら、その路線を `via_line_id` にした [`RouteNetwork::search`](crate::domain::route_search::RouteNetwork::search) で経路が + /// 見つかる。探索は目的地の駅グループで途中下車しないので、幅優先の到達判定 + /// だけでは「支線の根元の駅に、支線へ一度出てから戻って着く」ような経路を + /// 数えてしまう (石橋阪大前に箕面線で着く、など)。これが起きるのは駅グループが + /// 関節点のときだけなので、そのときに限り目的地で降りない探索で確かめる。 + pub fn can_arrive(&self, station_cd: i32, station_group_id: u32, line_cd: i32) -> bool { + if !self.station_cds.contains(&station_cd) { + return false; + } + let Some(&target) = self.topology.node_by_group.get(&station_group_id) else { + return false; + }; + // 出発駅グループ自身へは search が必ず 0 件を返す。到達集合には、別の駅から + // 同じ系統に乗り直した分として出発駅グループの駅が入ることがある + if self.origin == Some(target) { + return false; + } + if !self.topology.cut_nodes[target as usize] { + return true; + } + self.origin.is_some_and(|origin| { + self.topology + .arrives_without_stopover(origin, target, line_cd) + }) + } +} + +impl RouteTopology { + /// 系統ごとの駅列 (運行順) から網を組み立てる。結果は `line_groups` の順序に + /// 依存するので、呼び出し側は安定した順で渡すこと。 + pub fn build(line_groups: I) -> Self + where + I: IntoIterator>, + { + let mut topology = RouteTopology::default(); + for stops in line_groups { + topology.add_pattern(stops); + } + topology.finish(); + topology + } + + pub fn pattern_count(&self) -> usize { + self.patterns.len() + } + + /// 系統を 1 つ載せる。[`Self::finish`] を呼ぶまで到達判定には使えない。 + pub(crate) fn add_pattern(&mut self, mut stops: Vec) { + if !trim_pattern(&mut stops, |s| s.station_cd, |s| s.stoppable) { + return; + } + let pattern_index = self.patterns.len() as u32; + let mut nodes = Vec::with_capacity(stops.len()); + for (pos, stop) in stops.iter().enumerate() { + let next = self.node_by_group.len() as u32; + let node = *self + .node_by_group + .entry(stop.station_group_id) + .or_insert(next); + if node == next { + self.stop_patterns.push(Vec::new()); + } + if stop.stoppable { + self.stop_patterns[node as usize].push((pattern_index, pos as u32)); + } + nodes.push(node); + } + self.patterns.push(TopologyPattern { + nodes, + station_cds: stops.iter().map(|s| s.station_cd).collect(), + line_cds: stops.iter().map(|s| s.line_cd).collect(), + stoppable: stops.iter().map(|s| s.stoppable).collect(), + }); + } + + /// 全系統を載せ終えたら呼ぶ。関節点を求める。 + pub(crate) fn finish(&mut self) { + self.cut_nodes = self.find_cut_nodes(); + } + + /// 駅と系統を頂点、「系統がその駅に止まる」を辺とする二部グラフで、関節点に + /// なる駅を求める (Tarjan、O(駅 + 系統 + 停車))。再帰すると長い系統で + /// スタックが深くなるので、明示的なスタックで回す。 + fn find_cut_nodes(&self) -> Vec { + let node_count = self.stop_patterns.len(); + let vertex_count = node_count + self.patterns.len(); + // 系統側の隣接 (乗降できる駅、重複なし) + let pattern_nodes: Vec> = self + .patterns + .iter() + .map(|pattern| { + let mut nodes: Vec = (0..pattern.len()) + .filter(|&stop| pattern.stoppable[stop]) + .map(|stop| pattern.nodes[stop]) + .collect(); + nodes.sort_unstable(); + nodes.dedup(); + nodes + }) + .collect(); + let neighbor = |vertex: usize, index: usize| -> Option { + if vertex < node_count { + self.stop_patterns[vertex] + .get(index) + .map(|&(pattern, _)| node_count + pattern as usize) + } else { + pattern_nodes[vertex - node_count] + .get(index) + .map(|&node| node as usize) + } + }; + + const UNVISITED: u32 = u32::MAX; + let mut order = vec![UNVISITED; vertex_count]; + let mut low = vec![0u32; vertex_count]; + let mut cut = vec![false; node_count]; + let mut counter = 0u32; + for root in 0..node_count { + if order[root] != UNVISITED { + continue; + } + order[root] = counter; + low[root] = counter; + counter += 1; + let mut root_children = 0; + // (頂点, 親, 次に見る隣接の添字) + let mut stack: Vec<(usize, usize, usize)> = vec![(root, usize::MAX, 0)]; + while let Some(&mut (vertex, parent, ref mut next)) = stack.last_mut() { + if let Some(child) = neighbor(vertex, *next) { + *next += 1; + if child == parent { + continue; + } + if order[child] == UNVISITED { + order[child] = counter; + low[child] = counter; + counter += 1; + if vertex == root { + root_children += 1; + } + stack.push((child, vertex, 0)); + } else { + low[vertex] = low[vertex].min(order[child]); + } + continue; + } + stack.pop(); + if parent != usize::MAX { + low[parent] = low[parent].min(low[vertex]); + if parent != root && parent < node_count && low[vertex] >= order[parent] { + cut[parent] = true; + } + } + } + cut[root] = root_children > 1; + } + cut + } + + /// `origin` から、`target` の駅グループで途中下車せずに、`line_cd` の路線の + /// 列車で `target` に着けるか。[`RouteNetwork::search`](crate::domain::route_search::RouteNetwork::search) と同じ制約の到達判定。 + fn arrives_without_stopover(&self, origin: u32, target: u32, line_cd: i32) -> bool { + if origin == target { + return false; + } + let mut reached_nodes = vec![false; self.stop_patterns.len()]; + let mut boarded_patterns = vec![false; self.patterns.len()]; + reached_nodes[origin as usize] = true; + let mut frontier = vec![origin]; + for _ in 0..MAX_RIDES { + let mut next = Vec::new(); + for &node in &frontier { + for &(pattern_index, _) in &self.stop_patterns[node as usize] { + if std::mem::replace(&mut boarded_patterns[pattern_index as usize], true) { + continue; + } + let pattern = &self.patterns[pattern_index as usize]; + for stop in 0..pattern.len() { + let stop_node = pattern.nodes[stop]; + if !pattern.stoppable[stop] || stop_node == node { + continue; + } + if stop_node == target { + if pattern.line_cds[stop] == line_cd { + return true; + } + // 目的地では途中下車しない + continue; + } + if !std::mem::replace(&mut reached_nodes[stop_node as usize], true) { + next.push(stop_node); + } + } + } + } + if next.is_empty() { + break; + } + frontier = next; + } + false + } + + /// `from`(駅グループ ID)からの到達判定を作る。駅ごとの判定は + /// [`Reachability::can_arrive`]。 + pub fn reachability(&self, from: u32) -> Reachability<'_> { + Reachability { + topology: self, + origin: self.node_by_group.get(&from).copied(), + station_cds: self.reachable_station_cds(from), + } + } + + /// `from` から乗車 [`MAX_RIDES`] 本以内で降りられる駅 (`station_cd`)。 + /// + /// 駅は路線ごとの `station_cd` で返すので、「この駅にこの路線で着けるか」を + /// 表す。所要時間は見ないので、系統を幅優先でたどるだけで済む。目的地で + /// 途中下車する経路も数えるため、探索より広いことがある (厳密な判定は + /// [`Reachability::can_arrive`])。 + pub(crate) fn reachable_station_cds(&self, from: u32) -> HashSet { + let mut reachable = HashSet::new(); + let Some(&origin) = self.node_by_group.get(&from) else { + return reachable; + }; + // パターンに最初に乗った節点と、別の節点からも乗れたか。乗った駅で + // そのまま降りることはできないので、乗車駅と同じ駅グループの駅は、別の + // 節点からも乗れたときに初めて「降りられる駅」になる (日比谷線で中目黒に + // 着いただけでは、東横線の中目黒に東横線で着いたことにならない) + let mut first_board: Vec> = vec![None; self.patterns.len()]; + let mut boarded_elsewhere = vec![false; self.patterns.len()]; + let mut reached_nodes = vec![false; self.stop_patterns.len()]; + reached_nodes[origin as usize] = true; + let mut frontier = vec![origin]; + for _ in 0..MAX_RIDES { + let mut next = Vec::new(); + for &node in &frontier { + for &(pattern_index, _) in &self.stop_patterns[node as usize] { + let index = pattern_index as usize; + let pattern = &self.patterns[index]; + // 初めて乗るなら乗車駅以外、別の節点から乗り直すなら最初の + // 乗車駅だけが新たに降りられる駅になる + let (boarded_at, first_time) = match first_board[index] { + None => { + first_board[index] = Some(node); + (node, true) + } + Some(first) if first != node && !boarded_elsewhere[index] => { + boarded_elsewhere[index] = true; + (first, false) + } + Some(_) => continue, + }; + for stop in 0..pattern.len() { + let stop_node = pattern.nodes[stop]; + if !pattern.stoppable[stop] || (stop_node == boarded_at) == first_time { + continue; + } + reachable.insert(pattern.station_cds[stop]); + if !std::mem::replace(&mut reached_nodes[stop_node as usize], true) { + next.push(stop_node); + } + } + } + } + if next.is_empty() { + break; + } + frontier = next; + } + reachable + } +} diff --git a/stationapi/src/model.rs b/stationapi/src/model.rs index 236833ff..c800f448 100644 --- a/stationapi/src/model.rs +++ b/stationapi/src/model.rs @@ -311,22 +311,26 @@ pub struct Route { /// 乗換経路探索 (`connectedRoutes`) の経路 1 本。 #[derive(Clone, Debug, Default, PartialEq)] pub struct ConnectedRoute { - /// 推定所要時間(分)。最初の列車の発車から到着まで。乗換の徒歩と乗換先の - /// 待ち時間の見込みを含む。 - pub estimated_minutes: f64, - pub transfer_count: u32, pub legs: Vec, } +/// 乗換経路の区間の指定 (`estimateArrivalTimes` / `trainRoute` の `legs`)。 +/// `connectedRoutes` の区間の `trainTypes` から選んだ種別の `groupId` と、区間の +/// `fromStation.id`・`toStation.id`。乗降駅は駅グループで系統の中から引き当てる。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RouteLegRequest { + pub line_group_id: u32, + pub from_station_id: u32, + pub to_station_id: u32, +} + /// 乗換経路の 1 区間 (1 本の列車)。 #[derive(Clone, Debug, Default, PartialEq)] pub struct RouteLeg { - /// 探索が選んだ代表の列車種別。`routeTypes` と同じ形で、`group_id` は実在の系統。 - pub train_type: TrainType, /// この区間で乗れる列車種別すべて。`routeTypes(乗車駅グループ, 降車駅グループ, /// 降車駅の路線)` と同じ結果・同じ並び。 pub train_types: Vec, - /// 乗車駅。この系統が走る路線の駅。 + /// 乗車駅。探索が選んだ系統が走る路線の駅。 pub from_station: Station, /// 降車駅。次の区間の乗車駅 (同じ駅グループの別路線の駅のことがある) か目的地。 pub to_station: Station, diff --git a/stationapi/src/use_case/error.rs b/stationapi/src/use_case/error.rs index 6ef084e6..cd88a9e2 100644 --- a/stationapi/src/use_case/error.rs +++ b/stationapi/src/use_case/error.rs @@ -13,6 +13,9 @@ pub enum UseCaseError { Other(anyhow::Error), #[error("{0}")] Unexpected(String), + /// 引数どうしが矛盾している (区間がつながっていない、など)。 + #[error("{0}")] + InvalidArgument(String), } impl From for UseCaseError { diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index afb56a44..483ee83b 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -41,6 +41,7 @@ use crate::{ company_repository::CompanyRepository, line_repository::LineRepository, station_repository::StationRepository, train_type_repository::TrainTypeRepository, }, + route_search, segment_speed_table::{segment_override_applies_to_kind, segment_speed_override_kmh}, }, model::{self, ConnectedRoute, Route}, @@ -912,77 +913,95 @@ where v.reverse(); v }; - let sliced = self - .update_station_vec_with_attributes( - sliced, - Some(line_group_id), - TransportTypeFilter::RailAndBus, - false, - ) - .await?; - - let mut segments: Vec = Vec::with_capacity(sliced.len()); - // 経路スライス内で路線ごとに通過駅があるか。通過駅が無い路線では優等種別でも - // 実質各駅停車として走る(東急田園都市線の急行が半蔵門線内で各駅停車になる - // 直通など)ため、種別の速度を適用せず各停(Default)として扱う。 - // arrival_estimation と同様に端点は常に停車扱いとし、中間の通過駅だけを - // 優等扱いの根拠にする。 - let sliced_len = sliced.len(); - let mut line_has_pass: std::collections::HashMap = - std::collections::HashMap::new(); - for (i, station) in sliced.iter().enumerate() { - let is_endpoint = i == 0 || i + 1 == sliced_len; - let passed = !is_endpoint - && (station.stop_condition == model::StopCondition::Not || station.pass == Some(1)); - let entry = line_has_pass.entry(station.line_cd).or_insert(false); - *entry = *entry || passed; - } - let mut prev_stop: Option<(f64, f64, i32)> = None; - for station in sliced { - let stops = station.stop_condition != model::StopCondition::Not; + self.train_route_segments(sliced, line_group_id).await + } - let distance_from_previous = match prev_stop { - Some((plat, plon, _)) => haversine_distance(plat, plon, station.lat, station.lon), - None => 0.0, - }; + async fn estimate_connected_route_arrival_times( + &self, + legs: &[model::RouteLegRequest], + ) -> Result, UseCaseError> { + let group_of = self.validate_route_legs(legs).await?; + let params = EstimationParams::default(); + let walk_minutes = f64::from(route_search::TRANSFER_WALK_SECONDS) / 60.0; - let is_bus = station.transport_type == TransportType::Bus; - let kind = station.train_type.as_ref().and_then(|tt| tt.kind); - let effective_kind = if line_has_pass - .get(&station.line_cd) - .copied() - .unwrap_or(false) - { - kind - } else { - None + let mut result: Vec = Vec::new(); + for (index, leg) in legs.iter().enumerate() { + // trainRoute と同じく系統の駅列から切り出す。系統に無い乗降駅は駅グループで + // 引き当てるので、区間の trainTypes のどの種別を lineGroupId にしてもよい + let stations = self + .station_repository + .get_by_line_group_id(leg.line_group_id) + .await?; + let group_stops: Vec<&Station> = stations.iter().collect(); + let (from_group, to_group) = + (group_of[&leg.from_station_id], group_of[&leg.to_station_id]); + let segment = estimate_group_segment( + &group_stops, + SegmentEndpoints { + from_station_cd: leg.from_station_id, + to_station_cd: leg.to_station_id, + groups: Some((from_group, to_group)), + }, + false, + ¶ms, + ) + .ok_or_else(|| leg_not_found(leg))?; + + // 乗換では、乗換駅に歩いて着いた時刻を到着、乗換先の列車を待った後を + // 出発とする。見込みは connectedRoutes の所要時間と同じ (最初の列車の + // 待ち時間は含めない) + let (board_arrival, board_departure) = match result.last() { + Some(previous) if index > 0 => { + let wait_minutes = f64::from(route_search::boarding_wait_seconds( + group_stops.first().and_then(|s| s.kind), + )) / 60.0; + let arrival = previous.cumulative_minutes + walk_minutes; + (arrival, arrival + wait_minutes) + } + _ => (0.0, 0.0), }; - let mut profile = - resolve_speed_profile(station.line_cd, station.line_type, is_bus, effective_kind); - // 隣接駅ペア単位の較正(GTFS 実ダイヤ由来)があれば、このセグメントの - // 最高速度を路線単位のプロファイルより優先して上書きする(各停系のみ)。 - if !is_bus && segment_override_applies_to_kind(effective_kind) { - if let Some((_, _, prev_cd)) = prev_stop { - if let Some(v_kmh) = - segment_speed_override_kmh(station.line_cd, prev_cd, station.station_cd) - { - profile.max_speed = v_kmh / 3.6; - } + for (position, mut stop) in segment.into_iter().enumerate() { + if position == 0 { + stop.cumulative_minutes = board_arrival; + stop.departure_cumulative_minutes = board_departure; + } else { + stop.cumulative_minutes += board_departure; + stop.departure_cumulative_minutes += board_departure; } + result.push(stop); } - prev_stop = Some((station.lat, station.lon, station.station_cd)); - - let model_station: model::Station = station.into(); - segments.push(model::TrainRouteSegment { - station: Some(model_station), - stops, - distance_from_previous, - max_speed: profile.max_speed, - max_acceleration: profile.max_acceleration, - max_deceleration: profile.max_deceleration, - }); } + Ok(result) + } + async fn get_connected_train_route( + &self, + legs: &[model::RouteLegRequest], + ) -> Result, UseCaseError> { + let group_of = self.validate_route_legs(legs).await?; + let mut segments = Vec::new(); + for leg in legs { + let stations = self + .station_repository + .get_by_line_group_id(leg.line_group_id) + .await?; + // 区間は connectedRoutes が探した弧なので、環状線では継ぎ目を跨ぐ + // 短い弧を取る (estimateArrivalTimes と同じ選び方) + // 系統に無い乗降駅は駅グループで引き当てる (estimateArrivalTimes と同じ) + let (from_group, to_group) = + (group_of[&leg.from_station_id], group_of[&leg.to_station_id]); + let sliced = slice_group_stations( + stations, + SegmentEndpoints { + from_station_cd: leg.from_station_id, + to_station_cd: leg.to_station_id, + groups: Some((from_group, to_group)), + }, + ) + .ok_or_else(|| leg_not_found(leg))?; + // 区間ごとに別の列車なので、通過駅の有無や距離の起点も区間ごとに数える + segments.extend(self.train_route_segments(sliced, leg.line_group_id).await?); + } Ok(segments) } @@ -1028,14 +1047,7 @@ where return Ok(vec![]); } - // 探索は ID だけを扱うので、列車種別と乗降駅は経路が確定してから - // まとめて取得する - let line_group_ids: Vec = journeys - .iter() - .flat_map(|journey| journey.legs.iter().map(|leg| leg.line_group_id)) - .collect::>() - .into_iter() - .collect(); + // 探索は ID だけを扱うので、乗降駅と種別は経路が確定してからまとめて取得する let station_ids: Vec = journeys .iter() .flat_map(|journey| journey.legs.iter()) @@ -1046,34 +1058,6 @@ where .into_iter() .collect(); - // routeTypes と同じ形の列車種別 (系統ごとに最初の 1 行 + 系統の路線) - let train_types = self - .train_type_repository - .get_by_line_group_id_vec(&line_group_ids) - .await?; - let mut tt_lines = self - .line_repository - .get_by_line_group_id_vec(&line_group_ids) - .await?; - for line in tt_lines.iter_mut() { - line.line_symbols = self.get_line_symbols(line); - } - let train_type_by_type_cd: HashMap = train_types - .iter() - .filter_map(|tt| tt.type_cd.map(|cd| (cd, tt.clone()))) - .collect(); - let mut train_type_by_line_group: HashMap = HashMap::new(); - for mut train_type in train_types { - let Some(line_group_id) = train_type.line_group_cd.map(|id| id as u32) else { - continue; - }; - if train_type_by_line_group.contains_key(&line_group_id) { - continue; - } - self.attach_train_type_lines(&mut train_type, &tt_lines, &train_type_by_type_cd); - train_type_by_line_group.insert(line_group_id, train_type); - } - // 乗降駅は stations クエリと同じ付帯情報を付ける let stations: HashMap = self .get_stations_by_id_vec(&station_ids, TransportTypeFilter::Rail) @@ -1123,21 +1107,14 @@ where to_station.line_cd as u32, ); Some(model::RouteLeg { - train_type: model::TrainType::from( - train_type_by_line_group.get(&leg.line_group_id)?.clone(), - ), train_types: leg_train_types.get(&key)?.clone(), from_station: model::Station::from(from_station.clone()), to_station: model::Station::from(to_station.clone()), }) }) - // 種別か駅を引けない区間があれば、その経路は返さない + // 駅か種別を引けない区間があれば、その経路は返さない .collect::>>()?; - Some(ConnectedRoute { - estimated_minutes: f64::from(journey.total_seconds) / 60.0, - transfer_count: journey.transfer_count() as u32, - legs, - }) + Some(ConnectedRoute { legs }) }) .collect(); Ok(routes) @@ -1168,58 +1145,13 @@ where let mut result: Vec = Vec::new(); for group_stops in route_row_tree_map.values() { - // 先頭駅が末尾にも重複格納された「閉じた」環状データ(ポートライナー等)は、 - // そのままだとラップ時に閉じ駅が二重になるため重複終端を除いてから - // 環状判定・弧選択する。 - let mut route_stops: &[&Station] = group_stops.as_slice(); - if route_stops.len() > 1 - && route_stops[0].station_cd == route_stops[route_stops.len() - 1].station_cd - { - route_stops = &route_stops[..route_stops.len() - 1]; - } - - let from_pos = route_stops - .iter() - .position(|s| s.station_cd as u32 == from_station_id); - let to_pos = route_stops - .iter() - .position(|s| s.station_cd as u32 == to_station_id); - - // 始点・終点の両方がこの経路候補に含まれない、または同一駅の場合は対象外。 - let (Some(fi), Some(ti)) = (from_pos, to_pos) else { - continue; - }; - if fi == ti { - continue; - } - - // 経路全体ではなく、始点→終点の区間だけに絞り込んで推定する。 - // 環状経路(山手線・大阪環状線など)は線形スライスだと格納順の - // 継ぎ目(例: 品川⇔大崎)を跨ぐ乗車で逆側の弧を返してしまうため、 - // シームをラップする弧を選択する。 - // 迂回係数の較正母数には切り出し前の経路全体を渡す。切り出した区間の - // 駅間隔が路線平均と異なると較正が破綻するため(スライス較正バグ)。 - if is_circular_route(route_stops) { - let arc = select_circular_arc(route_stops, fi, ti, direction_id.is_some()); - result.extend(estimate_arrival_minutes_calibrated( - &arc, - route_stops, - ¶ms, - )); - } else if fi < ti { - result.extend(estimate_arrival_minutes_calibrated( - &route_stops[fi..=ti], - route_stops, - ¶ms, - )); - } else { - let mut segment: Vec<&Station> = route_stops[ti..=fi].to_vec(); - segment.reverse(); - result.extend(estimate_arrival_minutes_calibrated( - &segment, - route_stops, - ¶ms, - )); + if let Some(segment) = estimate_group_segment( + group_stops, + SegmentEndpoints::exact(from_station_id, to_station_id), + direction_id.is_some(), + ¶ms, + ) { + result.extend(segment); } } @@ -1234,6 +1166,138 @@ where TR: TrainTypeRepository, CR: CompanyRepository, { + /// 乗換経路の区間の並びが 1 本の経路としてつながっているか確かめる。 + /// 前の区間の降車駅と次の区間の乗車駅は同じ駅グループでなければならない。 + /// 返り値は、区間の乗降駅の `station_cd` -> 駅グループ。 + async fn validate_route_legs( + &self, + legs: &[model::RouteLegRequest], + ) -> Result, UseCaseError> { + if legs.is_empty() { + return Err(UseCaseError::InvalidArgument( + "legs には 1 つ以上の区間を指定してください".to_string(), + )); + } + // connectedRoutes は乗車 MAX_RIDES 本までしか返さない。区間ごとに駅の取得と + // 推定・付帯情報の付与が走るので、それを超える指定は断る + if legs.len() > route_search::MAX_RIDES { + return Err(UseCaseError::InvalidArgument(format!( + "legs は {} 区間までにしてください", + route_search::MAX_RIDES + ))); + } + let ids: Vec = legs + .iter() + .flat_map(|leg| [leg.from_station_id, leg.to_station_id]) + .collect::>() + .into_iter() + .collect(); + let group_of: HashMap = self + .station_repository + .get_by_id_vec(&ids) + .await? + .into_iter() + .map(|station| (station.station_cd as u32, station.station_g_cd)) + .collect(); + for id in &ids { + if !group_of.contains_key(id) { + return Err(UseCaseError::NotFound { + entity_type: "station", + entity_id: id.to_string(), + }); + } + } + for pair in legs.windows(2) { + if group_of[&pair[0].to_station_id] != group_of[&pair[1].from_station_id] { + return Err(UseCaseError::InvalidArgument(format!( + "区間がつながっていません: 降車駅 {} と次の乗車駅 {} は別の駅グループです", + pair[0].to_station_id, pair[1].from_station_id + ))); + } + } + Ok(group_of) + } + + /// 切り出した系統の駅列に付帯情報を付け、走行区間にする。 + async fn train_route_segments( + &self, + sliced: Vec, + line_group_id: u32, + ) -> Result, UseCaseError> { + let sliced = self + .update_station_vec_with_attributes( + sliced, + Some(line_group_id), + TransportTypeFilter::RailAndBus, + false, + ) + .await?; + + let mut segments: Vec = Vec::with_capacity(sliced.len()); + // 経路スライス内で路線ごとに通過駅があるか。通過駅が無い路線では優等種別でも + // 実質各駅停車として走る(東急田園都市線の急行が半蔵門線内で各駅停車になる + // 直通など)ため、種別の速度を適用せず各停(Default)として扱う。 + // arrival_estimation と同様に端点は常に停車扱いとし、中間の通過駅だけを + // 優等扱いの根拠にする。 + let sliced_len = sliced.len(); + let mut line_has_pass: std::collections::HashMap = + std::collections::HashMap::new(); + for (i, station) in sliced.iter().enumerate() { + let is_endpoint = i == 0 || i + 1 == sliced_len; + let passed = !is_endpoint + && (station.stop_condition == model::StopCondition::Not || station.pass == Some(1)); + let entry = line_has_pass.entry(station.line_cd).or_insert(false); + *entry = *entry || passed; + } + let mut prev_stop: Option<(f64, f64, i32)> = None; + for station in sliced { + let stops = station.stop_condition != model::StopCondition::Not; + + let distance_from_previous = match prev_stop { + Some((plat, plon, _)) => haversine_distance(plat, plon, station.lat, station.lon), + None => 0.0, + }; + + let is_bus = station.transport_type == TransportType::Bus; + let kind = station.train_type.as_ref().and_then(|tt| tt.kind); + let effective_kind = if line_has_pass + .get(&station.line_cd) + .copied() + .unwrap_or(false) + { + kind + } else { + None + }; + let mut profile = + resolve_speed_profile(station.line_cd, station.line_type, is_bus, effective_kind); + // 隣接駅ペア単位の較正(GTFS 実ダイヤ由来)があれば、このセグメントの + // 最高速度を路線単位のプロファイルより優先して上書きする(各停系のみ)。 + if !is_bus && segment_override_applies_to_kind(effective_kind) { + if let Some((_, _, prev_cd)) = prev_stop { + if let Some(v_kmh) = + segment_speed_override_kmh(station.line_cd, prev_cd, station.station_cd) + { + profile.max_speed = v_kmh / 3.6; + } + } + } + prev_stop = Some((station.lat, station.lon, station.station_cd)); + + let model_station: model::Station = station.into(); + segments.push(model::TrainRouteSegment { + station: Some(model_station), + stops, + distance_from_previous, + max_speed: profile.max_speed, + max_acceleration: profile.max_acceleration, + max_deceleration: profile.max_deceleration, + }); + } + + Ok(segments) + } + async fn get_stations_by_group_id_vec_no_types( &self, station_group_id_vec: &[u32], @@ -1769,6 +1833,131 @@ where } } +/// 系統の駅列から切り出す区間の両端。 +#[derive(Clone, Copy)] +struct SegmentEndpoints { + from_station_cd: u32, + to_station_cd: u32, + /// 指定すると、`station_cd` が系統に無い端を同じ駅グループ (from, to) の駅で + /// 引き当てる。乗換経路の区間で、選んだ種別が区間の乗降駅とは別の路線の駅に + /// 止まる場合 (中央線快速の三鷹と中央・総武線の三鷹など) に使う。 + groups: Option<(i32, i32)>, +} + +impl SegmentEndpoints { + fn exact(from_station_cd: u32, to_station_cd: u32) -> Self { + Self { + from_station_cd, + to_station_cd, + groups: None, + } + } + + /// 駅列の中の両端の位置。`station_cd` が一致する駅を優先し、無い端だけ駅 + /// グループで探す。直通系統は接続駅で同じ駅グループの駅を 2 行持つ (宇都宮線の + /// 上野と上野東京ラインの上野など) ので、候補が複数あれば区間が最も短くなる + /// 組を選ぶ (同じ長さなら先の行)。両端が同じ位置なら `None`。 + fn positions(&self, stations: &[&Station]) -> Option<(usize, usize)> { + let candidates = |station_cd: u32, group: Option| -> Vec { + let exact: Vec = stations + .iter() + .position(|s| s.station_cd as u32 == station_cd) + .into_iter() + .collect(); + if !exact.is_empty() { + return exact; + } + let Some(group) = group else { + return exact; + }; + stations + .iter() + .enumerate() + .filter(|(_, s)| s.station_g_cd == group) + .map(|(index, _)| index) + .collect() + }; + let froms = candidates(self.from_station_cd, self.groups.map(|(from, _)| from)); + let tos = candidates(self.to_station_cd, self.groups.map(|(_, to)| to)); + froms + .iter() + .flat_map(|&from| tos.iter().map(move |&to| (from, to))) + .filter(|(from, to)| from != to) + .min_by_key(|(from, to)| from.abs_diff(*to)) + } +} + +/// 1 系統の駅列 (sst.id 順) から `endpoints` の区間を切り出し、始点からの推定到着 +/// 時間を返す。両駅が含まれない、または同じ駅なら `None`。 +/// +/// 先頭駅が末尾にも重複格納された「閉じた」環状データ (ポートライナー等) は重複 +/// 終端を除いてから扱う。環状経路 (山手線・大阪環状線など) は、線形スライスだと +/// 格納順の継ぎ目 (例: 品川⇔大崎) を跨ぐ乗車で逆側の弧を返してしまうため、 +/// シームをラップする弧を選ぶ (`directed` なら格納順、そうでなければ短い方)。 +/// 迂回係数の較正母数には切り出し前の系統全体を渡す (切り出した区間の駅間隔が +/// 路線平均と異なると較正が破綻するため)。 +fn estimate_group_segment( + group_stops: &[&Station], + endpoints: SegmentEndpoints, + directed: bool, + params: &EstimationParams, +) -> Option> { + let mut route_stops: &[&Station] = group_stops; + if route_stops.len() > 1 + && route_stops[0].station_cd == route_stops[route_stops.len() - 1].station_cd + { + route_stops = &route_stops[..route_stops.len() - 1]; + } + let (from, to) = endpoints.positions(route_stops)?; + Some(if is_circular_route(route_stops) { + let arc = select_circular_arc(route_stops, from, to, directed); + estimate_arrival_minutes_calibrated(&arc, route_stops, params) + } else if from < to { + estimate_arrival_minutes_calibrated(&route_stops[from..=to], route_stops, params) + } else { + let mut segment: Vec<&Station> = route_stops[to..=from].to_vec(); + segment.reverse(); + estimate_arrival_minutes_calibrated(&segment, route_stops, params) + }) +} + +/// 1 系統の駅列 (sst.id 順) から `endpoints` の区間を進行順に切り出す。環状経路は [`estimate_group_segment`] と同じく短い方の弧を取るので、 +/// 乗換経路の区間の走行区間と推定到着時間が同じ駅列になる。両駅が含まれない、 +/// または同じ駅なら `None`。 +fn slice_group_stations( + mut stations: Vec, + endpoints: SegmentEndpoints, +) -> Option> { + if stations.len() > 1 && stations[0].station_cd == stations[stations.len() - 1].station_cd { + stations.pop(); + } + let refs: Vec<&Station> = stations.iter().collect(); + let (from, to) = endpoints.positions(&refs)?; + Some(if is_circular_route(&refs) { + select_circular_arc(&refs, from, to, false) + .into_iter() + .cloned() + .collect() + } else if from < to { + stations[from..=to].to_vec() + } else { + let mut segment = stations[to..=from].to_vec(); + segment.reverse(); + segment + }) +} + +/// 区間の乗降駅が指定された系統に見つからないときのエラー。 +fn leg_not_found(leg: &model::RouteLegRequest) -> UseCaseError { + UseCaseError::NotFound { + entity_type: "station in route", + entity_id: format!( + "{}-{} on line group {}", + leg.from_station_id, leg.to_station_id, leg.line_group_id + ), + } +} + /// Build a signature describing the stations a train type actually stops at within the /// requested from→to segment. /// @@ -3400,7 +3589,7 @@ mod tests { company_repository::CompanyRepository, line_repository::LineRepository, station_repository::StationRepository, train_type_repository::TrainTypeRepository, }, - route_search::{self, RouteNetwork}, + route_search::RouteNetwork, }; /// Configurable mock station repository for testing @@ -4786,52 +4975,31 @@ mod tests { } } - /// 区間ごとの (系統, 乗車駅, 降車駅) - fn leg_shapes(route: &ConnectedRoute) -> Vec<(u32, u32, u32)> { + /// 区間ごとの (乗車駅, 降車駅)。テストの駅は系統ごとに別の ID (系統 100 なら + /// 1xx) なので、どの系統の区間かも表す + fn leg_shapes(route: &ConnectedRoute) -> Vec<(u32, u32)> { route .legs .iter() - .map(|leg| { - ( - leg.train_type.group_id, - leg.from_station.id, - leg.to_station.id, - ) - }) + .map(|leg| (leg.from_station.id, leg.to_station.id)) .collect() } #[tokio::test] - async fn test_get_connected_routes_returns_legs_with_real_train_types() { + async fn test_get_connected_routes_returns_legs_on_real_line_groups() { let interactor = create_connected_route_interactor(); let routes = interactor.get_connected_routes(1, 4, None).await.unwrap(); assert_eq!( routes.iter().map(leg_shapes).collect::>(), - vec![ - vec![(100, 101, 102), (200, 202, 203), (300, 303, 304)], - vec![(500, 501, 504)], - ], + vec![vec![(101, 102), (202, 203), (303, 304)], vec![(501, 504)],], "the faster transfer route comes first and the direct detour is kept \ - as the route with the fewest transfers; every leg carries the real \ - line group and the stations of that line group" - ); - assert_eq!(routes[0].transfer_count, 2); - assert_eq!(routes[1].transfer_count, 0); - assert!(routes[0].estimated_minutes < routes[1].estimated_minutes); - assert!( - routes[0].estimated_minutes - > 2.0 - * f64::from( - route_search::TRANSFER_WALK_SECONDS - + route_search::boarding_wait_seconds(None), - ) - / 60.0, - "each transfer adds the walk and the wait for the next train" + as the route with the fewest transfers; every leg carries the stations \ + of the line group it rides" ); let first_leg = &routes[0].legs[0]; - assert_eq!(first_leg.train_type.name, "種別100"); + assert_eq!(first_leg.train_types[0].name, "種別100"); assert_eq!(first_leg.from_station.group_id, 1); assert_eq!(first_leg.to_station.group_id, 2); } @@ -4843,7 +5011,6 @@ mod tests { let routes = interactor.get_connected_routes(1, 4, None).await.unwrap(); let transfer_route = &routes[0]; let first_leg = &transfer_route.legs[0]; - assert_eq!(first_leg.train_type.group_id, 100); // 各区間の一覧は routeTypes(乗車駅グループ, 降車駅グループ, 降車駅の路線) // と同じ結果・同じ並び @@ -4883,7 +5050,7 @@ mod tests { .unwrap(); assert_eq!( via_direct.iter().map(leg_shapes).collect::>(), - vec![vec![(500, 501, 504)]] + vec![vec![(501, 504)]] ); let via_transfer = interactor .get_connected_routes(1, 4, Some(300)) @@ -4891,7 +5058,15 @@ mod tests { .unwrap(); assert_eq!(via_transfer.len(), 1); assert_eq!( - via_transfer[0].legs.last().unwrap().train_type.group_id, + via_transfer[0] + .legs + .last() + .unwrap() + .to_station + .line + .as_ref() + .unwrap() + .id, 300 ); assert!(interactor @@ -4912,10 +5087,7 @@ mod tests { let backward = interactor.get_connected_routes(4, 1, None).await.unwrap(); assert_eq!( backward.iter().map(leg_shapes).collect::>(), - vec![ - vec![(300, 304, 303), (200, 203, 202), (100, 102, 101)], - vec![(500, 504, 501)], - ] + vec![vec![(304, 303), (203, 202), (102, 101)], vec![(504, 501)],] ); assert!(interactor diff --git a/stationapi/src/use_case/traits/query.rs b/stationapi/src/use_case/traits/query.rs index 6fd18ace..336470a5 100644 --- a/stationapi/src/use_case/traits/query.rs +++ b/stationapi/src/use_case/traits/query.rs @@ -8,7 +8,7 @@ use crate::{ station::Station, station_number::StationNumber, train_type::TrainType, }, }, - model::{ConnectedRoute, Route, TrainRouteSegment}, + model::{ConnectedRoute, Route, RouteLegRequest, TrainRouteSegment}, use_case::error::UseCaseError, }; @@ -124,6 +124,17 @@ pub trait QueryUseCase: Send + Sync + 'static { to_station_group_id: u32, via_line_id: Option, ) -> Result, UseCaseError>; + /// 乗換経路 (`connectedRoutes` の区間の並び) の各駅の推定到着時間。 + /// 出発駅からの累積で、乗換ごとに徒歩と乗換先の待ち時間の見込みを加える。 + async fn estimate_connected_route_arrival_times( + &self, + legs: &[RouteLegRequest], + ) -> Result, UseCaseError>; + /// 乗換経路 (`connectedRoutes` の区間の並び) の走行区間を、区間の順につなげたもの。 + async fn get_connected_train_route( + &self, + legs: &[RouteLegRequest], + ) -> Result, UseCaseError>; async fn estimate_route_arrival_times( &self, from_station_id: u32,