diff --git a/AGENTS.md b/AGENTS.md index b2dbf5f7..5ab4d8f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,9 +78,10 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **GTFS bus integration** – `preprocessor/src/gtfs/` reads the GTFS feeds into an in-memory representation and then projects them onto the shared `stations` / `lines` / `types` / `station_station_types` tables (`gtfs/integrate.rs`). Only routes, stops, trips, and stop_times are read; calendar, shapes, feed_info, and agencies do not affect the output. Every configured GTFS feed is imported, including Seibu Bus and Keio Bus (both downloaded from ODPT with `ODPT_ACCESS_TOKEN`). Tokyu Bus ordinary-route `BusroutePattern`, `BusstopPole`, and `BusTimetable` JSON are converted into the same representation; pattern IDs become `shape_id` values so route variants remain queryable as bus TrainTypes. The Tokyu-operated Ota, Shinagawa, and Meguro community buses use their official GTFS feeds and matching JSON routes are excluded to prevent duplicates. `ODPT_ACCESS_TOKEN` is required for authenticated sources; without it those feeds are skipped with a warning rather than failing the build. Stops whose Tokyu JSON records omit coordinates remain available to name and route queries but not coordinate searches. `transport_type` (0: rail, 1: bus) on both `stations` and `lines` keeps rail and bus records queryable side by side. GTFS IDs are namespaced per feed before import to avoid cross-operator collisions. `line_cd` (100,000,000+), `station_cd` / `station_g_cd` (200,000,000+), and bus `type_cd` / `line_group_cd` (100,000,000+) are all deterministic fnv1a hashes that stay clear of the rail data ranges. Disable the entire bus pipeline with `DISABLE_BUS_FEATURE=true`. - **Bus stop translations (readings & English)** – GTFS-JP `translations.txt` layouts differ per feed, so `load_translations` (`preprocessor/src/gtfs/parse.rs`) 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). `load_stops` then looks a stop's translation up by stop_id first, then by name. Keying only by `record_id` would silently drop 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` — `stationapi/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. -- **Track distances** – `Station.trackDistanceFromPrevious` is the track length in meters from the station before it in the returned list, for clients that total a ride's distance (TrainLCD/MobileApp's ride log). It is distinct from `trainRoute`'s `distanceFromPrevious`, which stays the straight-line distance the app's running simulation relies on. The arrival estimation (`estimateArrivalTimes` and `trainRoute` with `Estimated`) uses the track length as the running distance (straight line × detour factor where none exists) together with the recalibrated speed tables (`speed_table` / `segment_speed_table`, `SpeedCalibration::Recalibrated`); `scripts/compute_speed_table.py` fits those tables with the same distances, so it needs `generated/` from `make data`. The ride times of `connectedRoutes` and `trainRoute` with `Legacy` keep the original estimation (straight line × detour factor and the frozen tables in `domain/legacy_speed_table.rs`, the `EstimationParams` default), so recalibrating never moves route search results. Route search ride times therefore differ from the ETA. Only `lineStations`, `lineGroupStations`, `trainRoute`, and `stations(ids)` fill it (`attach_track_distances` in `QueryInteractor`, called once the order is final). `stations(ids)` returns the stations in the order of `ids` — the repository keeps that order and enrichment never reorders — so a client passing a route's station IDs (MobileApp's `sids` deep links) gets the lengths between IDs adjacent in that order; a pair that is not adjacent in the track data (an ID list skipping stations) is `null`. The first station, the first station of each `trainRoute` leg, sections without N02 geometry, and every other query return `null`, and clients fall back to the straight line there. `preprocessor/src/track/` builds an undirected graph from N02's `RailroadSection` LineStrings, collects every pair adjacent in a line's `(e_sort, station_cd)` order or a line group's `station_station_types.id` order (with and without closed stations, plus the seam of loop services within 3 km), snaps each station to every track within 500 m, and takes the Dijkstra path minimizing `2 × snap offset + track length` — snapping to the nearest track alone picks another line of the same operator at large stations (Honmachi) and detours through a transfer station. It first measures on the operators of both stations' companies (`OPERATOR_ALIASES` maps companies whose name differs from N02's), then retries on every operator for lines running on another company's track; a path longer than max(3 × straight, straight + 5 km) counts as unmeasured, and a result shorter than the straight line is raised to it. Pairs in one station group are 0. `build.rs` writes the sorted pairs to `connections.bin`, and `index::track_distance` binary-searches it, so no index is built at isolate start. When bumping the N02 edition, change the URL and cache directory in `preprocessor/src/track/mod.rs` and the `Cache N02 railway data` step in `.github/actions/build-worker/action.yml` together and compare the unmeasured count in the `preprocessor` log. `docs/architecture.md` (駅間の線路の長さ) has the details. +- **Track distances** – `Station.trackDistanceFromPrevious` is the track length in meters from the station before it in the returned list, for clients that total a ride's distance (TrainLCD/MobileApp's ride log). It is distinct from `trainRoute`'s `distanceFromPrevious`, which stays the straight-line distance the app's running simulation relies on. The arrival estimation (`estimateArrivalTimes` and `trainRoute` with `Estimated`) uses the track length as the running distance (straight line × detour factor where none exists) together with the recalibrated speed tables (`speed_table` / `segment_speed_table`, `SpeedCalibration::Recalibrated`); `scripts/compute_speed_table.py` fits those tables with the same distances, so it needs `generated/` from `make data`. The ride times of `trainRoute` with `Legacy` keep the original estimation (straight line × detour factor and the frozen tables in `domain/legacy_speed_table.rs`, the `EstimationParams` default), so recalibrating never moves route search results. Connected route search uses the same track distances and recalibrated estimation as ETA. Only `lineStations`, `lineGroupStations`, `trainRoute`, and `stations(ids)` fill it (`attach_track_distances` in `QueryInteractor`, called once the order is final). `stations(ids)` returns the stations in the order of `ids` — the repository keeps that order and enrichment never reorders — so a client passing a route's station IDs (MobileApp's `sids` deep links) gets the lengths between IDs adjacent in that order; a pair that is not adjacent in the track data (an ID list skipping stations) is `null`. The first station, the first station of each `trainRoute` leg, sections without N02 geometry, and every other query return `null`, and clients fall back to the straight line there. `preprocessor/src/track/` builds an undirected graph from N02's `RailroadSection` LineStrings, collects every pair adjacent in a line's `(e_sort, station_cd)` order or a line group's `station_station_types.id` order (with and without closed stations, plus the seam of loop services within 3 km), snaps each station to every track within 500 m, and takes the Dijkstra path minimizing `2 × snap offset + track length` — snapping to the nearest track alone picks another line of the same operator at large stations (Honmachi) and detours through a transfer station. It first measures on the operators of both stations' companies (`OPERATOR_ALIASES` maps companies whose name differs from N02's), then retries on every operator for lines running on another company's track; a path longer than max(3 × straight, straight + 5 km) counts as unmeasured, and a result shorter than the straight line is raised to it. Pairs in one station group are 0. `build.rs` writes the sorted pairs to `connections.bin`, and `index::track_distance` binary-searches it, so no index is built at isolate start. When bumping the N02 edition, change the URL and cache directory in `preprocessor/src/track/mod.rs` and the `Cache N02 railway data` step in `.github/actions/build-worker/action.yml` together and compare the unmeasured count in the `preprocessor` log. `docs/architecture.md` (駅間の線路の長さ) has the details. - **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. 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), so ordering happens on the server: `sortBy: ConnectedRouteSort` picks `Recommended` (the ranking above, the default when omitted), `ArrivalTime` (the search's own ride time — excluding the first train's wait, and estimated with the original calibration, so it differs from what `estimateArrivalTimes` reports — then fewer transfers), or `TransferCount` (fewer transfers, then earlier arrival). `route_search::sort_journeys` only reorders the set `search` returned, with a stable sort so ties keep the recommended order; the set itself never depends on `sortBy`. 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 — `stationGroupIds`, the station groups from boarding to alighting in travel order including pass-through stations (the search's `JourneyLeg.station_group_ids` as-is — station groups rather than station IDs so the client can match them against whichever train type it picks, which may run on another line; a group appears twice on patterns such as the Oedo Line's Tochomae), 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, and `docs/route-search.md` documents the search internals (data structures, the scan, pruning, alternatives, determinism). +- **Connected routes** – Frequency-based RAPTOR evaluates the actual service using ETA's track distances and `routing_estimation_params()`. It returns `recommendedGroupId` for each leg and `totalSeconds` for each route; clients must use the evaluated service for their default selection. `trainTypes` remains the manually selectable alternatives, including the evaluated group even if routeTypes dedup omitted it. Do not rescore a truncated candidate set using local defaults. `data/route_service_rules.csv` controls timetable-required services and wait overrides; `data/route_stop_rules.csv` controls directional pickup/drop-off. Validate both with data_validator. Known timetable-dependent services are excluded before search and from the reachability topology. Legacy trainRoute and the ETA calibration remain unchanged. Normal and single-pass-line pattern scans are O(n), using monotone boarding queues. Multiple-pass-line patterns retain all boarding candidates and use O(n²) compiled interval tables/scans. Search keeps the original six-ride and eight-search limits; alternative searches deduplicate ban sets and rank before taking six results. See docs/route-search.md for costs, pruning, and limitations. + - 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) @@ -125,5 +126,7 @@ Commands this guide and `.claude/skills/create-pr` rely on: - When modifying `QueryInteractor`, ensure the enrichment steps (companies, train types, line symbols) still behave as expected. Double-check helper methods such as `update_station_vec_with_attributes` and `build_route_tree_map`. - Introducing new tables, endpoints, or feature flags must come with matching updates to this document and any other affected guidance. +Service-rule evidence must include the canonical `type_name`, `reason`, `checked_on`, and `source` in `data/route_service_rules.csv`. The validator checks names against IDs to prevent a numeric-ID typo from excluding unrelated services. Confirm restricted operation from a primary source and scope shared type IDs to the relevant line groups. See `docs/route-service-audit.md`; do not infer operating availability from calibration-only GTFS samples or a service name alone. + ## Maintenance Keep this guide aligned with the repository. If a workflow, environment requirement, or endpoint changes, update AGENTS.md in the same pull request so automation agents and contributors work from current instructions. diff --git a/Cargo.lock b/Cargo.lock index 049568c8..06b31578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,6 +474,7 @@ name = "data_validator" version = "0.1.0" dependencies = [ "csv", + "stationapi", ] [[package]] @@ -1690,6 +1691,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "csv", "serde", "serde_json", "thiserror 1.0.69", diff --git a/README.md b/README.md index 72a6527d..315791e4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A GraphQL API that provides nearby Japanese train stations and bus stops, runnin - For contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md). - For system architecture and design decisions, see [docs/architecture.md](docs/architecture.md). - For the design of the transfer route search (`connectedRoutes`, RAPTOR), see [docs/route-search.md](docs/route-search.md). +- For the source-backed operating constraints used by timetable-free search, see [docs/route-service-audit.md](docs/route-service-audit.md). - For technical debt analysis and architectural concerns, see [docs/technical_debt.md](docs/technical_debt.md). - For the record of the Cloudflare Workers migration, see [docs/cloudflare-workers-migration.md](docs/cloudflare-workers-migration.md). - For the published GraphQL schema, see [schema/public.graphql](schema/public.graphql). diff --git a/data/README.md b/data/README.md index a2dd142c..3d51edd8 100644 --- a/data/README.md +++ b/data/README.md @@ -307,3 +307,15 @@ preprocessor が国土数値情報の鉄道データ(N02)から自動で求 --- **注意**: データの品質は最終的な API の品質に直結します。正確で一貫性のあるデータ入力にご協力ください。なお、データの変更は Pull Request のマージ後に開発環境での動作確認を経て本番環境に反映されます。 + +## 経路探索の運行制約 + +`route_service_rules.csv` には `type_cd`、任意の `line_group_id`、 +`requires_timetable`、任意の `boarding_wait_seconds`、根拠の `source` を記載します。 +時刻表を入力しない経路探索では `requires_timetable` の系統を除きます。 +系統を指定した規則を種別全体の規則より優先します。 + +`route_stop_rules.csv` には種別と駅グループ、格納順・逆順の乗車・降車許可を +記載します。通過駅を停車駅へ変更する規則ではありません。 +両ファイルを変更した場合は `cargo run -p data_validator` で参照 ID と重複を検査し、 +Worker を再ビルドしてください。根拠は各行の `source` に記載します。 diff --git a/data/route_service_rules.csv b/data/route_service_rules.csv new file mode 100644 index 00000000..0643f808 --- /dev/null +++ b/data/route_service_rules.csv @@ -0,0 +1,26 @@ +type_cd,line_group_id,requires_timetable,boarding_wait_seconds,source,type_name,reason,checked_on +316,,true,,https://www.seiburailway.jp/railway/reservedtrain/s-train/,S-TRAIN(平日運行),曜日・時間帯限定,2026-10-13 +339,,true,,https://www.seiburailway.jp/railway/reservedtrain/s-train/,S-TRAIN(土休日運行),曜日・時間帯限定,2026-10-13 +379,188,true,,https://seibu.ekitan.com/norikae/timetable/station/235-0/d1,快速,池袋発着の限定系統,2026-10-13 +379,620,true,,https://seibu.ekitan.com/norikae/timetable/station/235-0/d1,快速,池袋発着の限定系統,2026-10-13 +211,,true,,https://timetable.jr-odekake.net/train-timetable/38491?date=20261003,サンライズ瀬戸,夜行・少数便,2026-10-13 +212,,true,,https://timetable.jr-odekake.net/train-timetable/38491?date=20261003,サンライズ出雲,夜行・少数便,2026-10-13 +236,,true,,https://www.jreast.co.jp/railway/joyful/slgunma.html,"SLぐんま +みなかみ",運転日指定の臨時列車,2026-10-13 +237,,true,,https://www.jreast.co.jp/railway/joyful/slgunma.html,"SLぐんま +よこかわ",運転日指定の臨時列車,2026-10-13 +292,,true,,https://www.jr-odekake.net/railroad/mahorobalimitedexpress/,まほろば,土休日中心・平日臨時運転あり,2026-10-13 +294,,true,,https://www.jrhokkaido.co.jp/CM/Info/press/pdf/20260515_KO_2026summer.pdf,ニセコ,運転日指定の季節列車,2026-10-13 +295,,true,,https://www.jrhokkaido.co.jp/CM/Info/press/pdf/20260515_KO_2026summer.pdf,フラノラベンダーエクスプレス,運転日指定の季節列車,2026-10-13 +400,,true,,https://www.keio.co.jp/zasekishitei/,京王ライナー(京王八王子方面),時間帯限定・方向別乗降制限,2026-10-13 +401,,true,,https://www.keio.co.jp/zasekishitei/,京王ライナー(橋本方面),時間帯限定・方向別乗降制限,2026-10-13 +402,,true,,https://www.keikyu.co.jp/ride/train/wing_detail.html,モーニングウィング号,平日朝・片方向,2026-10-13 +403,,true,,https://www.keikyu.co.jp/ride/train/wing_detail.html,イブニングウィング号,平日夕夜・片方向・乗車専用駅あり,2026-10-13 +405,,true,,https://www.seiburailway.jp/railway/reservedtrain/haijimaliner/,拝島ライナー,朝夕限定・方向別乗降条件,2026-10-13 +406,,true,,https://www.tobu.co.jp/railway/tj/timetable/,TJライナー(下り),下り夕夜・上りとは別系統,2026-10-13 +407,,true,,https://www.tobu.co.jp/railway/tj/timetable/,TJライナー(上り),上り朝・下りとは別系統,2026-10-13 +412,965,true,,https://timetables.jreast.co.jp/2610/timetable-v/127d1p.html,ライナー,あいの風ライナー・平日限定便,2026-10-13 +412,1018,true,,https://www.keihan.co.jp/traffic/liner/,ライナー,京阪ライナー・平日朝夕夜限定,2026-10-13 +412,1019,true,,https://www.keihan.co.jp/traffic/liner/,ライナー,京阪ライナー・平日朝夕夜限定,2026-10-13 +510,,true,,https://www.keio.co.jp/zasekishitei/index_mt.html,Mt.TAKAO,土休日・少数便・方向別乗降制限,2026-10-13 +519,,true,,https://www.tobu.co.jp/railway/th/timetable/,THライナー,朝夕夜限定・方向別乗降制限,2026-10-13 diff --git a/data/route_stop_rules.csv b/data/route_stop_rules.csv new file mode 100644 index 00000000..3566e6b4 --- /dev/null +++ b/data/route_stop_rules.csv @@ -0,0 +1,2 @@ +type_cd,station_group_id,forward_board,forward_alight,reverse_board,reverse_alight,source +316,2200106,false,true,false,false,https://www.seiburailway.jp/railway/station/nerima/ diff --git a/data_validator/Cargo.toml b/data_validator/Cargo.toml index 7677b3dd..d127d16e 100644 --- a/data_validator/Cargo.toml +++ b/data_validator/Cargo.toml @@ -5,3 +5,4 @@ edition = "2021" [dependencies] csv = "1.3.0" +stationapi = { path = "../stationapi" } diff --git a/data_validator/src/main.rs b/data_validator/src/main.rs index bbcc0f8e..b49c6e50 100644 --- a/data_validator/src/main.rs +++ b/data_validator/src/main.rs @@ -62,9 +62,95 @@ fn main() -> Result<(), Box> { .map(|row| row.get(1).unwrap().parse::().unwrap()) .collect(); + // Keep the evidence attached to its actual canonical type, rather than a + // silently mistyped numeric ID (e.g. commuter express vs holiday S-TRAIN). + let type_names: std::collections::HashMap = records + .iter() + .map(|row| (row[1].parse().unwrap(), &row[2])) + .collect(); + let mut rules_reader = + ReaderBuilder::new().from_path(data_path.join("route_service_rules.csv"))?; + let headers = rules_reader.headers()?.clone(); + let column = |name| headers.iter().position(|h| h == name).expect(name); + for row in rules_reader.records() { + let row = row?; + let type_cd = row[column("type_cd")].parse()?; + assert_eq!( + type_names.get(&type_cd).copied(), + Some(&row[column("type_name")]), + "service rule name does not match canonical type {type_cd}" + ); + assert!( + row[column("source")].starts_with("https://"), + "missing rule source" + ); + assert!( + !row[column("reason")].trim().is_empty(), + "missing rule reason" + ); + assert!( + !row[column("checked_on")].trim().is_empty(), + "missing rule verification date" + ); + } + + let station_groups: HashSet = station_records + .iter() + .map(|r| r[1].parse().unwrap()) + .collect(); + let mut seen = HashSet::new(); + for rule in stationapi::domain::service_rules::parse_service_rules(&std::fs::read_to_string( + data_path.join("route_service_rules.csv"), + )?)? { + assert!( + type_ids.contains(&(rule.type_cd as u32)), + "unknown rule type" + ); + assert!( + seen.insert((rule.type_cd, rule.line_group_id)), + "duplicate service rule" + ); + assert!( + rule.boarding_wait_seconds.is_none_or(|s| s >= 0), + "negative wait" + ); + } + let mut seen = HashSet::new(); + for rule in stationapi::domain::service_rules::parse_stop_rules(&std::fs::read_to_string( + data_path.join("route_stop_rules.csv"), + )?)? { + assert!( + type_ids.contains(&(rule.type_cd as u32)), + "unknown stop rule type" + ); + assert!( + station_groups.contains(&rule.station_group_id), + "unknown stop rule group" + ); + assert!( + seen.insert((rule.type_cd, rule.station_group_id)), + "duplicate stop rule" + ); + } + let mut rdr = ReaderBuilder::new().from_path(data_path.join("5!station_station_types.csv"))?; let records: Vec = rdr.records().collect::, _>>()?; + for rule in stationapi::domain::service_rules::parse_service_rules(&std::fs::read_to_string( + data_path.join("route_service_rules.csv"), + )?)? { + if let Some(group) = rule.line_group_id { + assert!( + records + .iter() + .any( + |r| r.get(2).and_then(|v| v.parse::().ok()) == Some(rule.type_cd) + && r.get(3).and_then(|v| v.parse::().ok()) == Some(group) + ), + "unknown service rule group" + ); + } + } for record in &records { let line = || record.iter().collect::>().join(","); diff --git a/docs/architecture.md b/docs/architecture.md index df63c905..62696dbf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -342,36 +342,32 @@ connectedRoutes(fromStationGroupId: Int!, toStationGroupId: Int!, viaLineId: Int enum ConnectedRouteSort { Recommended ArrivalTime TransferCount } -type ConnectedRoute { legs: [RouteLeg!] } -type RouteLeg { trainTypes: [TrainType!] fromStation: Station toStation: Station stationGroupIds: [Int!] } +type ConnectedRoute { totalSeconds: Int! legs: [RouteLeg!] } +type RouteLeg { recommendedGroupId: Int! trainTypes: [TrainType!] fromStation: Station toStation: Station stationGroupIds: [Int!] } ``` `stationGroupIds` は、乗車駅から降車駅までの駅グループ ID を進行順に並べた もの (通過駅を含む) で、探索で使った系統の駅の並びをそのまま返します。 駅 ID ではなく駅グループ ID にしているのは、アプリが区間ごとに選ぶ種別 -(既定は各駅停車) が、探索で使った系統とは別の路線を走ることがあるためです。 +(手動で変更した場合) が、探索で使った系統とは別の路線を走ることがあるためです。 駅 ID は路線ごとに異なりますが、駅グループ ID ならどの種別の駅リストとも 照合できます。アプリはこの並びに沿って、選んだ種別の駅リストから駅を 取り出すので、環状線でどちら回りにするかをアプリ側で判断する必要は ありません。同じ駅グループが 2 回現れる系統 (大江戸線の都庁前) では、 直前に取り出した駅に隣接するほうを選びます。 -探索では、停車駅が同じ並行種別 (中央線の快速と通勤快速など) を 1 つの経路に -まとめ、代替経路を再探索するときもそれらをまとめて除外します。そのままでは -並行種別がレスポンスに一度も現れず、種別一覧 (TrainTypeListModal) を表示して -各駅停車を既定で選ぶという、現在のアプリの挙動を維持できません。そこで -`trainTypes` には、その区間で乗車できる種別をすべて返します。中身は -`routeTypes(乗車駅グループ, 降車駅グループ, 降車駅の路線)` の結果と同一で、 -停車駅が同じ種別の集約、路線の付与、並び順も `routeTypes` と同じです (同じ -関数を呼び出しています)。探索が選んだ代表の種別、推定所要時間、乗換回数は -並べ替えにだけ使います。アプリでは使わないため、API では返しません。 +探索では、停車駅が同じ並行種別を一つの経路にまとめます。 +`trainTypes` は手動で変更できる種別一覧です。`routeTypes` と同じ取得処理を使い、 +探索した系統が集約で省かれた場合はその系統も加えます。 +`recommendedGroupId` は探索で評価した系統です。アプリは既定選択にこの ID を +使います。`totalSeconds` は最初の待ち時間を除いた、乗換の徒歩と待ち時間を +含む秒数です。既定の各停に置き換えて順位を付け直す処理はありません。 `viaLineId` は `routeTypes` と同様に、検索結果でタップした駅の路線を指します。 指定すると、目的地にその路線の駅で到着する経路 (最後の区間がその路線を走る 経路) だけに絞り込みます。 -`sortBy` は経路の並び順です。推定所要時間と乗換回数は API で返さないので、 -並べ替えはサーバー側で行います。どれを選んでも返す経路の集合は変わらず、 +`sortBy` は経路の並び順です。並べ替えはサーバー側で行います。どれを選んでも返す経路の集合は変わらず、 並びだけが変わります。 | `sortBy` | 並び | @@ -382,10 +378,9 @@ type RouteLeg { trainTypes: [TrainType!] fromStation: Station toStation: Stati 到着の早い順は、`estimateArrivalTimes` と同じ定義の所要時間 (最初の列車の 待ち時間は含めず、乗換ごとに徒歩時間と乗換先の待ち時間を加える) で並べます。 -ただし、並べ替えに使うのは探索が選んだ代表の種別での見込みです。アプリが -区間ごとに別の種別 (既定の各駅停車など) を選んで `estimateArrivalTimes` で -求めた到着見込みとは、並びが一致しないことがあります。キーが同じ経路同士は、 -おすすめ順のまま並びます。 +探索で選んだ `recommendedGroupId` の系統で見積もります。 +ユーザーが別の種別へ変更した場合は、その種別の ETA を求め直します。 +キーが同じ経路同士は、おすすめ順のまま並びます。 ### 系統網 @@ -395,9 +390,7 @@ type RouteLeg { trainTypes: [TrainType!] fromStation: Station toStation: Stati またぐ乗車も同じ配列で計算できるようにしています。対象は鉄道だけで、バスは 含みません。 -網の構築には全系統の駅と所要時間の推定が必要で、ネイティブ環境で約 190ms -かかります (`data/*.csv` の 1,185 系統・41,706 行。時間の半分が `Station` の -組み立て、残り半分が推定)。そのため起動時には構築せず、最初に +網は全系統の駅と区間時分表から構築します。最初に `connectedRoutes` が呼ばれたときに `OnceLock` に構築し、isolate が生きている 間は使い回します。 @@ -495,9 +488,8 @@ input RouteLegInput { lineGroupId: Int! fromStationId: Int! toStationId: Int! ます。`Estimated` は、線路の長さ (`connections`) がある駅間ではそれを走行距離に 使い、無い駅間だけ直線距離 × 迂回係数で見積もり、その距離で求め直した較正 (`speed_table` / `segment_speed_table`) を使います。`estimateArrivalTimes` も同じ -計算です。乗換経路探索 (`connectedRoutes`) の所要時間だけは、元の計算 (直線距離 × -迂回係数と、`domain/legacy_speed_table.rs` の元の較正) のままです。そのため、 -経路検索の所要時間と ETA は一致しません。`Legacy` の値で台形の速度プロファイルを作って走らせると、 +計算です。乗換経路探索 (`connectedRoutes`) も同じ距離と較正で評価します。 +`Legacy` の値で台形の速度プロファイルを作って走らせると、 `estimateArrivalTimes` より短い時間で走り切ります (#1709)。所要時間を推定に 合わせたいクライアントは、`Estimated` の見込みを使います。 @@ -567,7 +559,8 @@ input RouteLegInput { lineGroupId: Int! fromStationId: Int! toStationId: Int! ### 計算量 -1 回の探索の計算量は O(乗車回数 × 走査したパターンの駅数) です。旧実装 +通常系統と通過路線が一つの系統の走査は O(n)、通過路線が複数の系統は O(n²) です。 +各走査を乗車回数ぶん行います。旧実装 (列車種別と降車駅のすべての組み合わせを、乗換回数の分だけ列挙する深さ制限 つきの BFS) との比較は次のとおりです (ネイティブの release ビルド、 `data/*.csv` を使用)。 @@ -767,7 +760,7 @@ repository の実装がないメソッドは、空の結果ではなく `DomainE │ │ ├── entity/ # Station / Line / TrainType / Company ... │ │ ├── repository/ # 抽象インターフェース │ │ ├── arrival_estimation.rs -│ │ ├── legacy_speed_table.rs # 元の較正 (connectedRoutes と trainRoute の Legacy が使う) +│ │ ├── legacy_speed_table.rs # 元の較正 (trainRoute の Legacy が使う) │ │ ├── route_search.rs # 乗換経路探索 (RAPTOR) │ │ ├── route_topology.rs # 所要時間を持たない系統網 (stationsByName の到達判定) │ │ ├── segment_speed_table.rs diff --git a/docs/route-search.md b/docs/route-search.md index ed7f2272..e4e7e1ee 100644 --- a/docs/route-search.md +++ b/docs/route-search.md @@ -124,12 +124,12 @@ Pattern - 乗降できる駅が 2 つ未満の系統は網に含めません。 - この関数は `RouteTopology` と共有しているので、2 つの網は必ず同じ系統から 同じ形で構築されます (実データのテストで一致を確認しています)。 -3. `arrival_estimation::estimate_arrival_minutes_calibrated` で、駅ごとに - 累積の到着時刻・出発時刻を求め、秒単位に丸めます。 +3. `estimate_arrival_minutes_with_profile` で、順方向・逆方向の累積時分を + 秒単位の浮動小数点で保持します。乗車区間の差を取ってから整数秒へ丸めます。 4. 「このノードでは、このパターンのこの位置で乗降できる」という対応を `stop_patterns` に登録します。 -構築にはネイティブ環境で約 190ms かかります。そのため、最初に +構築は全系統に対して一度行います。そのため、最初に `connectedRoutes` が呼ばれたときに `OnceLock` に構築し、isolate が生きている 間は使い回します。 @@ -226,11 +226,9 @@ base + arrival[q] 正しく数えられなくなるためです。そのため、このラウンドで降車した駅から、 同じラウンドのうちに別の列車へ乗り継ぐこともありません。 -**後ろ向きの走査**。パターンは片方向の並びしか持たないので、逆方向の乗車は、 -所要時間が上りと下りで同じだと仮定して求めます。乗車時のキーを -`+arrival[q]`、降車時のキーを `−departure[q]` にすると、位置 `b` から -`q` (`q < b`) までの乗車時間は `arrival[b] − departure[q]` になり、前向きと -同じ式で扱えます。 +**後ろ向きの走査**。駅列を逆順にした推定値を別に構築し、その累積時分を +使います。上りと下りを対称と仮定して順方向の出発・到着を入れ替える処理は +行いません。区間の通過路線に応じた処理は「所要時間と運行条件」を参照してください。 **走査範囲**。前向きの走査は改善した位置の最小値 `lo` から、後ろ向きの走査は 最大値 `hi` から始めます。それより手前には前ラウンドで改善した駅がなく、 @@ -272,8 +270,7 @@ base + arrival[q] pareto = raptor(禁止なし, 上限なし) queue = pareto の各経路について、区間を乗車時間の長い順に 1 つずつ禁止した集合 while queue から禁止集合を取り出せる間: - 探索回数が MAX_SEARCH_RUNS (8) 以上になるか、結果が MAX_JOURNEYS (6) 件に - 達したら終了 + 探索回数が MAX_SEARCH_RUNS (8) 以上なら終了 for 経路 in raptor(禁止集合, 上限 = score_limit + 1): 許容範囲外・逆戻り・既出のいずれかなら捨てる この経路の区間をさらに 1 つずつ禁止した集合を queue に追加する @@ -314,25 +311,46 @@ while queue から禁止集合を取り出せる間: パレート解は必ず残し、残りの枠を代替経路で埋めます。最後に「評価値 + 乗換 1 回につき 5 分」の小さい順、同点なら乗車回数の少ない順に並べ、最大 6 件を -返します。これがおすすめ順 (`JourneySort::Recommended`) です。 +返します。API はこの評価を保ち、各区間の `recommendedGroupId` と経路の +`totalSeconds` を返します。種別一覧の各停で再評価する処理はありません。 + +`ArrivalTime` は `(totalSeconds, 乗換回数)`、`TransferCount` は +`(乗換回数, totalSeconds)` で安定ソートします。候補の集合は変えません。 +初回のパレート解は保持します。代替候補は最大 8 回の異なる禁止集合で探索し、 +全候補を評価してから最大 6 件に絞ります。厳密な k 最短経路の列挙ではありません。 + +### 所要時間と運行条件 + +探索、枝刈り、順位付けには ETA と同じ線路距離と `routing_estimation_params` +を使います。`trainRoute` の `Legacy` と ETA の較正値は変更しません。 +通過駅がない区間で各停速度を使う ETA の規則も維持します。 + +区間によらず速度が同じ系統は、順方向と逆方向の累積時分から乗車時間を引きます。 +通過路線が一つの系統では、各停のラベル、通過前の候補、通過後のラベルを +単調キューで保持し、O(n) で正確に走査します。通過路線が複数の系統では、区間ごとの通過路線集合について +累積時分を一度ずつ構築し、区間時分表を作ります。探索時には表だけを参照します。 +同じ系統の複数の乗車位置を保持し、全系統の累積時分だけで乗車位置を一つに +絞ってしまう処理を避けます。通常系統と通過路線が一つの系統は O(n)、通過路線が複数の系統は O(n²) の走査です。 +区間時分表の容量は O(n²)、構築は O(n² + p × n) です。p は実際の区間に現れる +通過路線集合の数です。待ち時間は系統の先頭ではなく乗車位置で決めます。 + +`data/route_service_rules.csv` は種別または系統単位の運行制約を持ちます。 +`requires_timetable` の系統は、時刻表を入力しない探索の前に除きます。 +S-TRAINに限らず、既存の全国の種別・系統を確認し、公式資料で曜日・時間帯・ +運転日が限定されると確認できた列車にも同じ基準を適用します。 +対象と根拠は [運行制約の確認記録](route-service-audit.md) に記載しています。 +同じ快速でも池袋発着の188・620以外の系統にはその規則を適用しません。 +「ライナー」412は複数社で共有するため、965・1018・1019を個別に登録します。 +手動選択用の種別一覧には残します。これは検索日時のない探索に対する暫定制約で、 +運行時間内の便を検索して推薦する機能ではありません。 +東武のFライナーや各社の快速急行を名称だけで一律に除外する処理はありません。 +各行に種別名・判断理由・確認日・出典を残し、検証時に種別IDと名前を照合します。 + +`data/route_stop_rules.csv` は格納順と逆順の乗車・降車許可を持ちます。 +練馬の平日 S-TRAIN は下りの降車だけを許可します。根拠は CSV の `source` +に記載します。未登録の運行条件を推測して追加する処理はありません。 +両 CSV は `data_validator` で参照 ID、重複、待ち時間を検査します。 -`connectedRoutes` の `sortBy` で到着の早い順や乗換の少ない順を選ぶと、use case -層が `search` の結果を `sort_journeys` で並べ替えます。経路の集め方は変えず、 -並びだけを変えます。 - -| `JourneySort` | キー (小さい順) | -|---|---| -| `Recommended` | `search` の並びのまま | -| `ArrivalTime` | (`total_seconds`, 乗換回数) | -| `TransferCount` | (乗換回数, `total_seconds`) | - -到着の早い順は `total_seconds` (最初の列車の待ち時間を含まない所要時間) で -並べます。`estimateArrivalTimes` が返す見込みと定義は同じですが、値は探索が -選んだ代表の種別でのものです。そのため、アプリが別の種別 (既定の各駅停車など) -を選んで求め直した見込みとは、並びが一致しないことがあります。候補は待ち時間 -込みの評価値で集めているので、待ち時間を除いたときだけ速くなる本数の少ない -特急が、新たに候補に加わることはありません。乗換が最も少ない経路は、パレート解 -として必ず候補に含まれています。 ## 決定性 @@ -343,7 +361,7 @@ while queue から禁止集合を取り出せる間: - 値が同じ場合は、先に記録したものを残す (`<` のときだけ更新する) - 次ラウンドの `marked`: ノード番号順 - 並べ替えは安定ソートで行い、同点の場合は乗車回数、次に発見順で並べる -- `sort_journeys` も安定ソートで、キーが同じ経路はおすすめ順を保つ +- `connectedRoutes` の並べ替えも安定ソートで、キーが同じ経路は探索時のおすすめ順を保つ これは `search_is_deterministic` テストで確認しています。 @@ -367,8 +385,6 @@ while queue から禁止集合を取り出せる間: - **時刻表がない**: 実際の接続 (何分待つか)、終電、季節運行の臨時列車は 考慮しません。待ち時間は種別から見積もった値です。 -- **所要時間は上下で同じとみなす**: 後ろ向きの走査は、上りと下りの所要時間が - 同じだと仮定しています。 - **駅グループをまたぐ徒歩連絡はない**: 徒歩連絡のデータを持たないので、 別の駅グループへ歩いて乗り換える経路は扱いません。 - **乗換の徒歩時間は一律 3 分**: 駅ごとの乗換時間の違いは反映しません。 @@ -414,3 +430,19 @@ while queue から禁止集合を取り出せる間: 行っています。実データでの処理時間は [アーキテクチャドキュメントの「計算量」](./architecture.md#計算量) を 参照してください。 + +## 性能の比較 + +同じ生成済み鉄道データで `route_network_performance_report` を実行しました。 +ネイティブの debug ビルドで、5 経路を各 10 回検索した値です。 +比較元は `c06c728`、共通処理変更後の測定対象は `4847a9` です。 +Worker 上での時間を示す値ではありません。 + +| 実装 | 系統数 | 初回構築 | 検索の中央値 | 95 パーセンタイル | +|---|---:|---:|---:|---:| +| 比較元 | 1,342 | 987 ms | 44.2 ms | 66.3 ms | +| 共通時分・運行制約・区間走査 | 1,327 | 1,892 ms | 125.4 ms | 296.3 ms | + +区間に応じた速度評価、逆方向の時分、最後まで実行する代替探索によって計算量が +増えています。単一の通過路線は単調キューで O(n) に保ち、複数の通過路線だけに +区間表を使います。構築は `OnceLock` で一度だけ実行します。 diff --git a/docs/route-service-audit.md b/docs/route-service-audit.md new file mode 100644 index 00000000..4e75fb39 --- /dev/null +++ b/docs/route-service-audit.md @@ -0,0 +1,57 @@ +# 時刻表を入力しない探索の運行制約 + +確認日: 2026-10-13。 + +`connectedRoutes` は検索日時を持たず、固定の待ち時間で探索します。 +曜日・時間帯・運転日が限定される便を、常時その待ち時間で乗れる候補として +扱うと、運行していない便による短絡や乗換が上位になります。 +S-TRAINだけでなく、登録済みの全国の種別・系統を対象に、同じ条件を確認します。 + +今回の制約は便の利用可否を正確に判定する機能ではありません。公式資料で +限定運行を確認できた系統を、日時を持たない自動探索と到達判定から除く暫定措置です。 +手動選択・駅一覧・ETAのための元データは維持します。特急、快速急行、有料指定席、 +名称に「ライナー」が含まれる、という理由だけでは除外しません。 + +## 確認した対象 + +種別IDは `4!types.csv`、系統IDは `5!station_station_types.csv` に照合しました。 +実行に使う行は `route_service_rules.csv` にあり、各行に名前・理由・確認日・出典を記録します。 + +| 列車 | 種別ID / 限定する系統 | 判断の根拠 | +| --- | --- | --- | +| S-TRAIN | 316・339 | [西武鉄道](https://www.seiburailway.jp/railway/reservedtrain/s-train/): 平日・土休日の別、限定時間帯、方向別乗降条件 | +| 池袋発着の西武快速 | 379 / 188・620 | [公式池袋時刻表](https://seibu.ekitan.com/norikae/timetable/station/235-0/d1): 該当系統の限定便。他の快速系統は対象外 | +| 京王ライナー | 400・401 | [京王電鉄](https://www.keio.co.jp/zasekishitei/): 限定時間帯、上りの途中駅の乗降制限 | +| モーニング・イブニングウィング | 402・403 | [京急電鉄](https://www.keikyu.co.jp/ride/train/wing_detail.html): 平日朝・夕夜の片方向運転。ウィング・シート付き一般列車は対象外 | +| 拝島ライナー | 405 | [西武鉄道](https://www.seiburailway.jp/railway/reservedtrain/haijimaliner/): 朝夕の限定便、方向別乗降条件 | +| TJライナー | 406・407 | [東武鉄道](https://www.tobu.co.jp/railway/tj/timetable/): 上り朝、下り夕夜。快速急行341とは別種別 | +| あいの風ライナー | 412 / 965 | [JR東日本掲載の2026年10月時刻表](https://timetables.jreast.co.jp/2610/timetable-v/127d1p.html): 平日限定便 | +| 京阪ライナー | 412 / 1018・1019 | [京阪電鉄](https://www.keihan.co.jp/traffic/liner/): 平日朝夕夜の限定便。通常の特急・プレミアムカーは対象外 | +| Mt.TAKAO | 510 | [京王電鉄](https://www.keio.co.jp/zasekishitei/index_mt.html): 土休日の少数便、方向別乗降条件 | +| THライナー | 519 | [東武鉄道](https://www.tobu.co.jp/railway/th/timetable/): 朝・夕夜の限定便、東武線と日比谷線の乗降専用区間 | +| サンライズ瀬戸・出雲 | 211・212 | [JR西日本掲載の2026年10月時刻表](https://timetable.jr-odekake.net/train-timetable/38491?date=20261003): 夜行便。昼間も15分待ちで乗れる便として扱わない | +| SLぐんま 水上・横川 | 236・237 | [JR東日本](https://www.jreast.co.jp/railway/joyful/slgunma.html): 運転日指定の臨時列車。2026年秋はGVけん引の案内があり、SL登録系統を常時運行として扱わない | +| まほろば | 292 | [JR西日本](https://www.jr-odekake.net/railroad/mahorobalimitedexpress/): 土休日中心。2026年秋は指定された平日にも臨時運転 | +| ニセコ・フラノラベンダーエクスプレス | 294・295 | [JR北海道2026年夏の臨時列車案内](https://www.jrhokkaido.co.jp/CM/Info/press/pdf/20260515_KO_2026summer.pdf): 季節・運転日指定の臨時列車 | + +## GTFSとの関係と残る範囲 + +既存の鉄道GTFSは速度較正と実旅行時間の検証に使っています。今回、GTFSの +`calendar`・`calendar_dates`・便の発着時刻が探索につながったとは扱いません。 +較正用の平日昼間のサンプルから「それ以外には運転しない」と判断することもありません。 +上表は公式の運行条件と既存の登録系統を照合したもので、GTFSからの自動生成ではありません。 + +他の通勤種別、少数運転の特急、季節・臨時列車にも未登録の条件が残ります。 +今回の表で全国の調査が完了したとは扱いません。対象を増やす際は、運行条件と +登録系統の対応を出典で確認し、共有種別IDなら系統を限定します。 +便・方向・区間により条件が変わる場合は種別全体へ安易に広げません。 +将来GTFSの運行情報を探索へ接続する際は、確認できる便を日時に応じて推薦できるよう、 +該当系統の暫定除外を置き換えます。 + +## 検証 + +- 登録した制約の種別名とIDが一致することをdata_validatorで検証します。 +- 実データの対象系統が自動探索に入らず、逆方向にも架空の便を作らず、 + 手動選択用の一覧には残ることを検証します。 +- 通常の快速・快速急行・通勤特急が巻き込まれないことと、到達判定と探索の網が + 一致することを検証します。 diff --git a/schema/public.graphql b/schema/public.graphql index e84628a8..77d25fa0 100644 --- a/schema/public.graphql +++ b/schema/public.graphql @@ -109,6 +109,7 @@ type Query { } type ConnectedRoute { + totalSeconds: Int! legs: [RouteLeg!] } @@ -305,6 +306,7 @@ input RouteLegInput { } type RouteLeg { + recommendedGroupId: Int! trainTypes: [TrainType!] fromStation: Station toStation: Station diff --git a/src/graphql/enums.rs b/src/graphql/enums.rs index 28dc046e..ef5cb1a4 100644 --- a/src/graphql/enums.rs +++ b/src/graphql/enums.rs @@ -93,10 +93,11 @@ pub enum TtsAlphabet { #[derive(Enum, Copy, Clone, Eq, PartialEq)] #[graphql(rename_items = "PascalCase", name = "ConnectedRouteSort")] pub enum ConnectedRouteSort { + // 各区間の既定種別 (各停を優先、無ければ先頭) で評価して並べる。 // おすすめ順 (評価値 = 最初の列車の待ち時間を含む所要時間の見込み、に乗換 1 回あたり // 5 分を足した値の小さい順)。既定 Recommended, - // 到着の早い順 (探索の所要時間。元の較正で見積もるので estimateArrivalTimes の + // 到着の早い順 (既定種別の所要時間。元の較正で見積もるので estimateArrivalTimes の // 見込みとは一致しない)。同じなら乗換の少ない順 ArrivalTime, // 乗換の少ない順。同じなら到着の早い順 diff --git a/src/graphql/types.rs b/src/graphql/types.rs index 8c164d53..63aef5b3 100644 --- a/src/graphql/types.rs +++ b/src/graphql/types.rs @@ -320,12 +320,14 @@ impl From for Route { #[derive(SimpleObject)] #[graphql(name = "ConnectedRoute")] pub struct ConnectedRoute { + pub total_seconds: i32, pub legs: Option>, } impl From for ConnectedRoute { fn from(v: model::ConnectedRoute) -> Self { Self { + total_seconds: v.total_seconds, legs: Some(v.legs.into_iter().map(Into::into).collect()), } } @@ -345,6 +347,7 @@ pub struct RouteLegInput { #[derive(SimpleObject)] #[graphql(name = "RouteLeg")] pub struct RouteLeg { + pub recommended_group_id: i32, // この区間で乗れる種別すべて。routeTypes(乗車駅グループ, 降車駅グループ, // 降車駅の路線) と同じ結果・同じ並び pub train_types: Option>, @@ -359,6 +362,7 @@ pub struct RouteLeg { impl From for RouteLeg { fn from(v: model::RouteLeg) -> Self { Self { + recommended_group_id: v.recommended_group_id as i32, 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/lib.rs b/src/lib.rs index f7a140c7..1c0c2139 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -122,3 +122,33 @@ fn with_cors(response: Response) -> Result { headers.set("content-type", "text/plain; charset=utf-8")?; Ok(response.with_headers(headers)) } + +#[cfg(test)] +mod route_metadata_tests { + use std::future::Future; + #[test] + fn schema_and_response_expose_the_evaluated_service() { + let schema = crate::graphql::build_schema(crate::interactor()); + if let Ok(path) = std::env::var("STATIONAPI_SCHEMA_OUT") { + std::fs::write(path, schema.sdl()).unwrap(); + } + let future = schema.execute("{ connectedRoutes(fromStationGroupId: 1130221, toStationGroupId: 9930138) { totalSeconds legs { recommendedGroupId trainTypes { groupId } } } }"); + let mut context = std::task::Context::from_waker(std::task::Waker::noop()); + let result = match std::pin::pin!(future).poll(&mut context) { + std::task::Poll::Ready(value) => value, + _ => panic!("in-memory execution must complete"), + }; + assert!(result.errors.is_empty(), "{:?}", result.errors); + let data = result.data.into_json().unwrap(); + for route in data["connectedRoutes"].as_array().unwrap() { + assert!(route["totalSeconds"].as_i64().unwrap() > 0); + for leg in route["legs"].as_array().unwrap() { + assert!(leg["trainTypes"] + .as_array() + .unwrap() + .iter() + .any(|tt| tt["groupId"] == leg["recommendedGroupId"])); + } + } + } +} diff --git a/src/repository.rs b/src/repository.rs index f0333017..6368be82 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, OnceLock}; +#[cfg(test)] use stationapi::domain::arrival_estimation::EstimationParams; use stationapi::domain::entity::company::Company; use stationapi::domain::entity::gtfs::TransportType; @@ -158,9 +159,10 @@ fn route_network() -> &'static Arc { } fn build_route_network() -> RouteNetwork { - RouteNetwork::build( + RouteNetwork::build_with_track( rail_line_group_cds().map(|group| stations_of_line_groups(&[group as u32])), - &EstimationParams::default(), + &stationapi::domain::arrival_estimation::routing_estimation_params(), + index::track_distance, ) } @@ -174,18 +176,27 @@ fn route_topology() -> &'static RouteTopology { } 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), + RouteTopology::build( + rail_line_group_cds() + .filter(|&group| { + !index::sst_by_group(group).next().is_some_and(|sst| { + stationapi::domain::service_rules::service_rule(Some(sst.type_cd), Some(group)) + .is_some_and(|r| r.requires_timetable) + }) }) - .collect() - })) + .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() + }), + ) } // ---------------------------------------------------------------- 駅 @@ -1256,6 +1267,115 @@ mod tests { const MITAKA: u32 = 1131105; const NAKA_MEGURO: u32 = 2600103; + /// S-TRAIN や快速で探索した時分を、アプリが既定で選ぶ各停の時分として + /// 順位付けしない。都庁前で 1 回乗り換える経路を先頭にする。 + #[test] + fn recommends_tochomae_transfer_between_ueno_okachimachi_and_hikarigaoka() { + use stationapi::use_case::traits::query::QueryUseCase; + if index::sst_by_group(1_000_099_301).next().is_none() { + // 大江戸線の各停は preprocessor が生成するため、data/ だけでは検索できない。 + return; + } + let interactor = crate::interactor(); + for (from, to) in [(1130221, 9930138), (9930138, 1130221)] { + let routes = block_on(interactor.get_connected_routes( + from, + to, + Some(99301), + JourneySort::Recommended, + )) + .unwrap(); + let first = &routes[0]; + assert_eq!(first.legs.len(), 2, "{routes:?}"); + assert_eq!(first.legs[0].to_station.group_id, 9930100); + assert_eq!(first.legs[1].from_station.group_id, 9930100); + assert!(first.legs.iter().all(|leg| leg + .train_types + .iter() + .any(|tt| tt.group_id == 1_000_099_301))); + for sort in [JourneySort::ArrivalTime, JourneySort::TransferCount] { + let sorted = + block_on(interactor.get_connected_routes(from, to, Some(99301), sort)).unwrap(); + assert_eq!(sorted.len(), routes.len()); + assert!(sorted.iter().all(|route| routes.contains(route))); + } + } + } + + #[test] + fn recommended_services_and_durations_match_eta() { + use stationapi::use_case::traits::query::QueryUseCase; + if index::sst_by_group(1_000_099_301).next().is_none() { + return; + } + let interactor = crate::interactor(); + for (from, to) in [ + (1130221, 9930138), + (9930138, 1130221), + (1130101, 1130205), + (1131105, 2600103), + (1131906, 1160155), + ] { + let routes = + block_on(interactor.get_connected_routes(from, to, None, JourneySort::Recommended)) + .unwrap(); + for route in routes { + let legs: Vec<_> = route + .legs + .iter() + .map(|leg| { + assert!(leg + .train_types + .iter() + .any(|tt| tt.group_id == leg.recommended_group_id)); + model::RouteLegRequest { + line_group_id: leg.recommended_group_id, + from_station_id: leg.from_station.id, + to_station_id: leg.to_station.id, + } + }) + .collect(); + let estimates = + block_on(interactor.estimate_connected_route_arrival_times(&legs)).unwrap(); + let expected = estimates.last().unwrap().cumulative_minutes * 60.0; + assert!( + (f64::from(route.total_seconds) - expected).abs() <= route.legs.len() as f64, + "{from}->{to}: {} / {expected}", + route.total_seconds + ); + } + } + } + + #[test] + #[ignore = "manual performance comparison; run with --ignored --nocapture"] + fn route_network_performance_report() { + let started = std::time::Instant::now(); + let network = build_route_network(); + let build = started.elapsed(); + let mut samples = Vec::new(); + for _ in 0..10 { + for (from, to) in [ + (1130221, 9930138), + (9930138, 1130221), + (1130101, 1130205), + (1131105, 2600103), + (1131906, 1160155), + ] { + let started = std::time::Instant::now(); + std::hint::black_box(network.search(from, to, None)); + samples.push(started.elapsed()); + } + } + samples.sort_unstable(); + eprintln!( + "PERF patterns={} build={build:?} median={:?} p95={:?}", + network.pattern_count(), + samples[25], + samples[47] + ); + } + /// repository の実装は await しない (索引を引くだけ) ので、1 回 poll すれば終わる fn block_on(future: F) -> F::Output { let mut context = std::task::Context::from_waker(std::task::Waker::noop()); @@ -1526,6 +1646,57 @@ mod tests { assert_eq!(route_topology(), route_network().topology()); } + #[test] + fn timetable_required_services_stay_selectable_but_do_not_create_routes() { + use stationapi::domain::service_rules::service_rule; + let params = stationapi::domain::arrival_estimation::routing_estimation_params(); + let mut excluded = 0; + for group in rail_line_group_cds() { + let stations = stations_of_line_groups(&[group as u32]); + let first = stations.first().expect("canonical service has stations"); + if !service_rule(first.type_cd, Some(group)).is_some_and(|r| r.requires_timetable) { + continue; + } + let selectable = + block_on(MemTrainTypeRepository.get_by_line_group_id(group as u32)).unwrap(); + assert!( + !selectable.is_empty(), + "manual selection lost group {group}" + ); + let from = stations.iter().find(|s| s.pass != Some(1)).unwrap(); + let to = stations.iter().rev().find(|s| s.pass != Some(1)).unwrap(); + let network = RouteNetwork::build([stations.clone()], ¶ms); + assert_eq!(network.pattern_count(), 0, "admitted group {group}"); + assert!(network + .search(from.station_cd as u32, to.station_cd as u32, None) + .is_empty()); + assert!(network + .search(to.station_cd as u32, from.station_cd as u32, None) + .is_empty()); + excluded += 1; + } + assert!( + excluded > 20, + "operating audit must cover more than the original services" + ); + } + + #[test] + fn regular_rapid_and_commuter_services_are_not_excluded_by_their_names() { + let params = stationapi::domain::arrival_estimation::routing_estimation_params(); + // Tobu rapid express, Tokyu/MM commuter limited express, Seibu rapid: + // shared kind/name/ID ranges must not spread a service-specific rule. + for group in [239, 184, 420, 1174] { + let stations = stations_of_line_groups(&[group]); + assert!(!stations.is_empty()); + let network = RouteNetwork::build([stations], ¶ms); + assert!( + network.pattern_count() > 0, + "regular group {group} excluded" + ); + } + } + #[test] fn stations_by_name_includes_stations_reached_by_transfer() { // 三鷹から中目黒へは直通の系統が無いが、乗り換えれば東横線でも日比谷線でも着く diff --git a/stationapi/Cargo.toml b/stationapi/Cargo.toml index eb5efedf..cf45e97b 100644 --- a/stationapi/Cargo.toml +++ b/stationapi/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" # I/O を伴う依存は入れない。 [dependencies] +csv = "1" async-trait = "0.1.68" serde = { version = "1.0.189", features = ["derive"] } anyhow = "1.0.71" diff --git a/stationapi/src/domain.rs b/stationapi/src/domain.rs index 5bb535e7..fd79125a 100644 --- a/stationapi/src/domain.rs +++ b/stationapi/src/domain.rs @@ -10,3 +10,5 @@ pub mod route_search; pub mod route_topology; pub mod segment_speed_table; pub mod speed_table; + +pub mod service_rules; diff --git a/stationapi/src/domain/arrival_estimation.rs b/stationapi/src/domain/arrival_estimation.rs index 22985f56..0fb21250 100644 --- a/stationapi/src/domain/arrival_estimation.rs +++ b/stationapi/src/domain/arrival_estimation.rs @@ -594,6 +594,18 @@ pub fn estimate_arrival_minutes_with_track( calibration_stops: &[&Station], params: &EstimationParams, track: Option<&TrackDistances>, +) -> Vec { + estimate_arrival_minutes_with_profile(stops, calibration_stops, params, track, None) +} + +/// Compile cumulative estimates with a fixed interval's pass-line profile. +/// The normal public estimator still derives the profile from its requested slice. +pub fn estimate_arrival_minutes_with_profile( + stops: &[&Station], + calibration_stops: &[&Station], + params: &EstimationParams, + track: Option<&TrackDistances>, + profile: Option<&HashMap>, ) -> Vec { let n = stops.len(); if n == 0 { @@ -661,6 +673,10 @@ pub fn estimate_arrival_minutes_with_track( *entry = *entry || !stops_here[i]; } + if let Some(profile) = profile { + line_has_pass.clone_from(profile); + } + let line_group_of = |station: &Station| -> Option { station.line_group_cd.or(Some(station.line_cd)) }; @@ -773,6 +789,14 @@ pub fn estimate_arrival_minutes_with_track( result } +/// Shared time model for route search and the arrival-time API; Legacy is unchanged. +pub fn routing_estimation_params() -> EstimationParams { + EstimationParams { + speed_calibration: SpeedCalibration::Recalibrated, + ..EstimationParams::default() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/stationapi/src/domain/route_search.rs b/stationapi/src/domain/route_search.rs index 660c9fe2..7a8102b0 100644 --- a/stationapi/src/domain/route_search.rs +++ b/stationapi/src/domain/route_search.rs @@ -15,7 +15,7 @@ //! 徒歩 [`TRANSFER_WALK_SECONDS`] を加える。同じ駅グループ内でのみ乗り換え、 //! 駅グループを跨ぐ徒歩連絡は扱わない。 //! - 1 回の探索で走査するのは「前ラウンドで到着が改善した駅を通るパターン」 -//! だけで、計算量は O(乗車回数 × 触れたパターンの駅数)。駅と路線の全組み合わせを +//! だけ。通常・単一通過路線は O(n)、複数通過路線は O(n²) の区間走査を行う。駅と路線の全組み合わせを //! 列挙しない。 //! //! パレート解は最短と最少乗換しか含まないため、代替経路は「見つかった経路の @@ -26,12 +26,15 @@ use std::collections::{HashMap, HashSet, VecDeque}; +#[cfg(test)] +use crate::domain::arrival_estimation::estimate_arrival_minutes_with_track; use crate::domain::arrival_estimation::{ - estimate_arrival_minutes_with_track, is_circular_route, track_distance_key, EstimationParams, + estimate_arrival_minutes_with_profile, is_circular_route, track_distance_key, EstimationParams, TrackDistances, }; use crate::domain::entity::station::Station; use crate::domain::route_topology::{trim_pattern, RouteStop, RouteTopology}; +use crate::domain::service_rules; /// 乗換 1 回あたりの乗換通路の徒歩(秒)。 pub const TRANSFER_WALK_SECONDS: i32 = 3 * 60; @@ -74,6 +77,23 @@ fn rank_score(cost: i64, legs: &[LegRef]) -> i64 { cost + TRANSFER_RANK_SECONDS * legs.len().saturating_sub(1) as i64 } +/// The frequent special case: interval speed depends on one line's passed stops. +#[derive(Clone, Debug)] +struct SinglePassTimes { + arrival: Vec, + departure: Vec, + reverse_arrival: Vec, + reverse_departure: Vec, + pass_prefix: Vec, +} + +fn push_board(queue: &mut VecDeque<(f64, usize)>, base: f64, board: usize) { + while queue.back().is_some_and(|&(old, _)| base <= old) { + queue.pop_back(); + } + queue.push_back((base, board)); +} + /// 1 系統(列車種別の停車パターン)。 #[derive(Clone, Debug)] struct Pattern { @@ -84,13 +104,19 @@ struct Pattern { line_cds: Vec, /// 乗降できる(= 通過ではない)か。 stoppable: Vec, + pickup: [Vec; 2], + dropoff: [Vec; 2], /// 展開位置ごとの到着・出発(秒)。線形なら `nodes.len()` 要素、環状なら /// 二周ぶん `2 * nodes.len() + 1` 要素で、位置 `q` の駅は `nodes[q % len]`。 - arrival: Vec, - departure: Vec, + arrival: Vec, + departure: Vec, + reverse_arrival: Vec, + reverse_departure: Vec, + interval_seconds: Option>, + single_pass: Option, circular: bool, /// 乗車時に評価値へ加える平均待ち時間(秒)。 - boarding_wait: i32, + boarding_wait: Vec, } impl Pattern { @@ -102,6 +128,32 @@ impl Pattern { fn span(&self) -> usize { self.arrival.len() } + + fn ride_seconds(&self, board: usize, alight: usize) -> i32 { + if let Some(times) = &self.interval_seconds { + return times[board * self.span() + alight]; + } + if let Some(times) = &self.single_pass { + let passing = + times.pass_prefix[board.max(alight)] > times.pass_prefix[board.min(alight)]; + if passing { + return (if board < alight { + times.arrival[alight] - times.departure[board] + } else { + let end = self.span() - 1; + times.reverse_arrival[end - alight] - times.reverse_departure[end - board] + }) + .round() as i32; + } + } + let seconds = if board < alight { + self.arrival[alight] - self.departure[board] + } else { + let end = self.span() - 1; + self.reverse_arrival[end - alight] - self.reverse_departure[end - board] + }; + seconds.round() as i32 + } } /// 乗車 1 回ぶん。`board` / `alight` はパターンの展開位置。 @@ -146,8 +198,7 @@ pub enum JourneySort { #[default] Recommended, /// 到着の早い順。[`Journey::total_seconds`] (最初の列車の待ち時間を含まない) の - /// 小さい順で、同じなら乗換の少ない順。所要時間は元の較正で見積もるので、 - /// `estimateArrivalTimes` の見込みとは一致しない。 + /// 小さい順で、同じなら乗換の少ない順。所要時間は ETA と同じモデルで推定する。 ArrivalTime, /// 乗換の少ない順。同じなら到着の早い順。 TransferCount, @@ -222,6 +273,65 @@ impl RouteNetwork { self.patterns.len() } + /// 指定された系統の乗車時間と乗車位置の待ち時間 (秒)。 + /// 駅 ID が系統に無ければ同じ駅グループで引き当て、環状線では短い弧を使う。 + pub fn ride_seconds( + &self, + line_group_id: u32, + from_station_cd: i32, + from_group: u32, + to_station_cd: i32, + to_group: u32, + ) -> Option<(i32, i32)> { + let from = *self.node_by_group.get(&from_group)?; + let to = *self.node_by_group.get(&to_group)?; + let mut best = None; + for &(pattern_id, _) in &self.stop_patterns[from as usize] { + let pattern = &self.patterns[pattern_id as usize]; + if pattern.line_group_id != line_group_id { + continue; + } + let positions = |node: u32, cd: i32| { + let exact = self.stop_patterns[node as usize] + .iter() + .any(|&(id, pos)| id == pattern_id && pattern.station_cds[pos as usize] == cd); + self.stop_patterns[node as usize] + .iter() + .filter(move |&&(id, pos)| { + id == pattern_id && (!exact || pattern.station_cds[pos as usize] == cd) + }) + .map(|&(_, pos)| pos as usize) + }; + for board in positions(from, from_station_cd) { + for alight in positions(to, to_station_cd) { + let direct = board.abs_diff(alight); + if direct == 0 { + continue; + } + let wraps = pattern.circular && pattern.len() - direct < direct; + let (board, alight, length) = if wraps && board < alight { + (board + pattern.len(), alight, pattern.len() - direct) + } else if wraps { + (board, alight + pattern.len(), pattern.len() - direct) + } else { + (board, alight, direct) + }; + let seconds = pattern.ride_seconds(board, alight); + // 同じグループが複数回出る系統も、ETA の切り出しと同じく + // 駅数が最も少ない区間を使う。同点は格納順を保つ。 + if best.is_none_or(|(len, _, _)| length < len) { + best = Some(( + length, + seconds, + pattern.boarding_wait[board % pattern.len()], + )); + } + } + } + } + best.map(|(_, seconds, wait)| (seconds, wait)) + } + fn node_of(&mut self, station_group_id: u32) -> u32 { let next = self.node_by_group.len() as u32; let node = *self.node_by_group.entry(station_group_id).or_insert(next); @@ -245,6 +355,12 @@ impl RouteNetwork { let Some(line_group_id) = stations.first().and_then(|s| s.line_group_cd) else { return; }; + if stations.first().is_some_and(|s| { + service_rules::service_rule(s.type_cd, s.line_group_cd) + .is_some_and(|r| r.requires_timetable) + }) { + return; + } let is_stoppable = |s: &Station| s.pass != Some(1) && s.stop_condition != crate::model::StopCondition::Not; // 系統の整え方は到達判定の網と共有する (同じ系統から同じ網を作るため) @@ -274,32 +390,106 @@ impl RouteNetwork { distances.insert(track_distance_key(a.station_cd, b.station_cd), d); } } - let estimated = if circular { - // 二周ぶん + 始点に戻る 1 駅を並べ、継ぎ目を跨ぐ乗車も同じ配列で引く。 - let mut unrolled: Vec<&Station> = Vec::with_capacity(refs.len() * 2 + 1); - unrolled.extend(refs.iter().copied()); - unrolled.extend(refs.iter().copied()); - unrolled.push(refs[0]); - estimate_arrival_minutes_with_track(&unrolled, &refs, params, Some(&distances)) + let unrolled: Vec<&Station> = if circular { + refs.iter() + .copied() + .cycle() + .take(2 * refs.len() + 1) + .collect() } else { - estimate_arrival_minutes_with_track(&refs, &refs, params, Some(&distances)) + refs.clone() }; - let to_seconds = |minutes: f64| -> i32 { - if minutes.is_finite() { - (minutes * 60.0).round() as i32 - } else { - 0 - } + let reverse: Vec<&Station> = unrolled.iter().copied().rev().collect(); + let span = unrolled.len(); + let cumulative = |stops: &[&Station], profile: &HashMap| { + let values = estimate_arrival_minutes_with_profile( + stops, + &refs, + params, + Some(&distances), + Some(profile), + ); + ( + values + .iter() + .map(|s| s.cumulative_minutes * 60.0) + .collect::>(), + values + .iter() + .map(|s| s.departure_cumulative_minutes * 60.0) + .collect::>(), + ) }; - let arrival: Vec = estimated + let pass_lines: HashSet = refs .iter() - .map(|e| to_seconds(e.cumulative_minutes)) + .filter(|s| !is_stoppable(s)) + .map(|s| s.line_cd) .collect(); - let departure: Vec = estimated + let variable_lines: HashSet = refs .iter() - .zip(&arrival) - .map(|(e, &arr)| to_seconds(e.departure_cumulative_minutes).max(arr)) + .filter(|s| pass_lines.contains(&s.line_cd) && s.kind.is_some_and(|k| k != 0)) + .map(|s| s.line_cd) .collect(); + let local = HashMap::new(); + let (arrival, departure) = cumulative(&unrolled, &local); + let (reverse_arrival, reverse_departure) = cumulative(&reverse, &local); + let single_pass = if variable_lines.len() == 1 { + let line = *variable_lines.iter().next().unwrap(); + let profile = HashMap::from([(line, true)]); + let (arrival, departure) = cumulative(&unrolled, &profile); + let (reverse_arrival, reverse_departure) = cumulative(&reverse, &profile); + let mut pass_prefix = vec![0]; + for station in &unrolled { + pass_prefix.push( + pass_prefix.last().unwrap() + + usize::from(station.line_cd == line && !is_stoppable(station)), + ); + } + Some(SinglePassTimes { + arrival, + departure, + reverse_arrival, + reverse_departure, + pass_prefix, + }) + } else { + None + }; + // Only services whose speed depends on the selected interval need a dense + // table. Compile each distinct pass-line profile once, never during search. + let interval_seconds = if variable_lines.len() <= 1 { + None + } else { + let mut profiles = HashMap::new(); + let mut times = vec![0; span * span]; + for board in 0..span { + let mut passed = HashSet::new(); + for alight in board + 1..span.min(board + refs.len() + usize::from(!circular)) { + let inside = unrolled[alight - 1]; + if alight > board + 1 + && !is_stoppable(inside) + && variable_lines.contains(&inside.line_cd) + { + passed.insert(inside.line_cd); + } + if !is_stoppable(unrolled[board]) || !is_stoppable(unrolled[alight]) { + continue; + } + let mut key: Vec = passed.iter().copied().collect(); + key.sort_unstable(); + let (fa, fd, ra, rd) = profiles.entry(key.clone()).or_insert_with(|| { + let profile = key.iter().map(|&line| (line, true)).collect(); + let (fa, fd) = cumulative(&unrolled, &profile); + let (ra, rd) = cumulative(&reverse, &profile); + (fa, fd, ra, rd) + }); + times[board * span + alight] = (fa[alight] - fd[board]).round() as i32; + times[alight * span + board] = + (ra[span - 1 - board] - rd[span - 1 - alight]).round() as i32; + } + } + Some(times) + }; let pattern_index = self.patterns.len() as u32; let nodes: Vec = stations @@ -317,10 +507,26 @@ impl RouteNetwork { station_cds: stations.iter().map(|s| s.station_cd).collect(), line_cds: stations.iter().map(|s| s.line_cd).collect(), stoppable, + pickup: [false, true].map(|forward| { + stations + .iter() + .map(|s| service_rules::board_allowed(s, forward)) + .collect() + }), + dropoff: [false, true].map(|forward| { + stations + .iter() + .map(|s| service_rules::alight_allowed(s, forward)) + .collect() + }), arrival, departure, + reverse_arrival, + reverse_departure, + interval_seconds, + single_pass, circular, - boarding_wait: boarding_wait_seconds(stations[0].kind), + boarding_wait: stations.iter().map(service_rules::boarding_wait).collect(), }); } @@ -375,13 +581,18 @@ impl RouteNetwork { // 再探索の禁止集合を幅優先で広げる。見つかった経路ごとに、区間を // 長い順に 1 つずつ禁止した子を積む let mut queue: VecDeque> = VecDeque::new(); - let push_children = |queue: &mut VecDeque>, - banned: &HashSet<(u32, u32)>, - legs: &[LegRef]| { + let mut seen_bans: HashSet> = HashSet::new(); + let mut push_children = |queue: &mut VecDeque>, + banned: &HashSet<(u32, u32)>, + legs: &[LegRef]| { for leg in self.legs_by_ride_time(legs) { let mut child = banned.clone(); self.ban_leg(leg, &mut child); - queue.push_back(child); + let mut key: Vec<_> = child.iter().copied().collect(); + key.sort_unstable(); + if seen_bans.insert(key) { + queue.push_back(child); + } } }; let root = HashSet::new(); @@ -392,7 +603,7 @@ impl RouteNetwork { let mut alternatives: Vec<(i64, Vec)> = Vec::new(); let mut runs = 1; while let Some(banned) = queue.pop_front() { - if runs >= MAX_SEARCH_RUNS || pareto.len() + alternatives.len() >= MAX_JOURNEYS { + if runs >= MAX_SEARCH_RUNS { break; } runs += 1; @@ -584,7 +795,7 @@ impl RouteNetwork { sorted.sort_by_key(|leg| { let pattern = &self.patterns[leg.pattern as usize]; let (a, b) = (leg.board as usize, leg.alight as usize); - std::cmp::Reverse(pattern.arrival[a.max(b)] - pattern.departure[a.min(b)]) + std::cmp::Reverse(pattern.ride_seconds(a, b)) }); sorted } @@ -678,7 +889,12 @@ impl RouteNetwork { // 評価値から、最初の列車を待つぶんだけを除いたものが所要時間 let first_wait = legs .first() - .map(|leg| i64::from(self.patterns[leg.pattern as usize].boarding_wait)) + .map(|leg| { + i64::from( + self.patterns[leg.pattern as usize].boarding_wait + [leg.board as usize % self.patterns[leg.pattern as usize].len()], + ) + }) .unwrap_or(0); let legs = legs .iter() @@ -739,7 +955,7 @@ impl RoundState<'_> { /// /// 乗車中は「基準値 + 降車キー」で各駅の到着を求める。前向きなら /// 到着 = 前ラウンドの評価値 + 徒歩 + 待ち − 出発(乗車駅) + 到着(降車駅)、 - /// 後ろ向きは所要時間が対称であることを使い 出発と到着の役割を入れ替える。 + /// 後ろ向きは逆方向の累積時分を使う。区間依存の速度は別の乗車ラベルで評価する。 fn scan>( &mut self, pattern_index: u32, @@ -748,29 +964,74 @@ impl RoundState<'_> { positions: I, ) { let len = pattern.len(); - // 乗車中なら (基準値, 乗車位置) - let mut boarded: Option<(i64, usize)> = None; + let end = pattern.span() - 1; + let mut boarded: VecDeque<(f64, usize)> = VecDeque::new(); + let mut candidates: Vec<(i64, usize)> = Vec::new(); + let mut passing: VecDeque<(f64, usize)> = VecDeque::new(); + let mut pending: VecDeque<(f64, usize)> = VecDeque::new(); for q in positions { let stop = q % len; if pattern.circular { - if let Some((_, board)) = boarded { - // 一周以上は乗らない - if q.abs_diff(board) >= len { - boarded = None; + while boarded + .front() + .is_some_and(|&(_, board)| q.abs_diff(board) >= len) + { + boarded.pop_front(); + } + candidates.retain(|&(_, board)| q.abs_diff(board) < len); + for queue in [&mut passing, &mut pending] { + while queue + .front() + .is_some_and(|&(_, board)| q.abs_diff(board) >= len) + { + queue.pop_front(); } } } + if pattern + .single_pass + .as_ref() + .is_some_and(|times| times.pass_prefix[q + 1] > times.pass_prefix[q]) + { + for (base, board) in pending.drain(..) { + push_board(&mut passing, base, board); + } + boarded.clear(); + } if !pattern.stoppable[stop] { continue; } let node = pattern.nodes[stop] as usize; - if let Some((base, board)) = boarded { - let alight_key = if forward { - i64::from(pattern.arrival[q]) - } else { - -i64::from(pattern.departure[q]) - }; - let arrival = base + alight_key; + let mut best_board = if pattern.interval_seconds.is_some() { + candidates + .iter() + .map(|&(base, board)| (base + i64::from(pattern.ride_seconds(board, q)), board)) + .min_by_key(|&(cost, _)| cost) + } else { + boarded.front().copied().map(|(base, board)| { + let arrival = if forward { + pattern.arrival[q] + } else { + pattern.reverse_arrival[end - q] + }; + ((base + arrival).round() as i64, board) + }) + }; + if let (Some(times), Some(&(base, board))) = (&pattern.single_pass, passing.front()) { + let value = base + + if forward { + times.arrival[q] + } else { + times.reverse_arrival[end - q] + }; + let candidate = (value.round() as i64, board); + if best_board.is_none_or(|old| candidate.0 < old.0) { + best_board = Some(candidate); + } + } + if let Some((arrival, board)) = + best_board.filter(|_| pattern.dropoff[usize::from(forward)][stop]) + { let accepted = node != self.arrival.node || self .arrival @@ -788,21 +1049,31 @@ impl RoundState<'_> { } } let reached = self.previous[node]; - if reached == UNREACHED { + if !pattern.pickup[usize::from(forward)][stop] + || reached == UNREACHED + || self.banned.contains(&(pattern_index, node as u32)) + { continue; } - let board_key = if forward { - -i64::from(pattern.departure[q]) + let cost = reached + self.transfer_walk + i64::from(pattern.boarding_wait[stop]); + if pattern.interval_seconds.is_some() { + candidates.push((cost, q)); } else { - i64::from(pattern.arrival[q]) - }; - let candidate = - reached + self.transfer_walk + i64::from(pattern.boarding_wait) + board_key; - // 禁止の照合はハッシュを引くので、乗り直しが得になるときだけ行う - if boarded.is_none_or(|(base, _)| candidate < base) - && !self.banned.contains(&(pattern_index, node as u32)) - { - boarded = Some((candidate, q)); + let departure = if forward { + pattern.departure[q] + } else { + pattern.reverse_departure[end - q] + }; + let base = cost as f64 - departure; + push_board(&mut boarded, base, q); + if let Some(times) = &pattern.single_pass { + let departure = if forward { + times.departure[q] + } else { + times.reverse_departure[end - q] + }; + push_board(&mut pending, cost as f64 - departure, q); + } } } } @@ -814,6 +1085,199 @@ mod tests { use crate::domain::entity::gtfs::TransportType; use crate::model::StopCondition; + #[test] + fn compiled_interval_costs_match_independent_eta_in_both_directions() { + let mut stops = vec![ + stop(100, 10000, 1, 0.0, 0.0), + stop(100, 10001, 2, 0.01, 0.0), + stop(100, 10002, 3, 0.02, 0.0), + stop(100, 10003, 4, 0.03, 0.0), + stop(100, 10004, 5, 0.04, 0.0), + ]; + for station in &mut stops { + station.kind = Some(3); + } + stops[2].pass = Some(1); + stops[2].stop_condition = StopCondition::Not; + let params = crate::domain::arrival_estimation::routing_estimation_params(); + let network = RouteNetwork::build(vec![stops.clone()], ¶ms); + let pattern = &network.patterns[0]; + let calibration: Vec<_> = stops.iter().collect(); + for board in 0..stops.len() { + for alight in 0..stops.len() { + if board == alight || !pattern.stoppable[board] || !pattern.stoppable[alight] { + continue; + } + let mut slice: Vec<_> = stops[board.min(alight)..=board.max(alight)] + .iter() + .collect(); + if board > alight { + slice.reverse(); + } + let expected = + estimate_arrival_minutes_with_track(&slice, &calibration, ¶ms, None) + .last() + .unwrap() + .cumulative_minutes; + assert_eq!( + pattern.ride_seconds(board, alight), + (expected * 60.0).round() as i32, + "{board}->{alight}" + ); + } + } + } + + #[test] + fn multiple_pass_lines_keep_each_interval_profile() { + let mut stations: Vec<_> = (0..7) + .map(|i| stop(100, 10000 + i, i + 1, f64::from(i) * 0.01, 0.0)) + .collect(); + for (i, station) in stations.iter_mut().enumerate() { + station.kind = Some(3); + station.line_cd = if i < 3 { 100 } else { 200 }; + } + for i in [1, 4] { + stations[i].pass = Some(1); + stations[i].stop_condition = StopCondition::Not; + } + let params = crate::domain::arrival_estimation::routing_estimation_params(); + let network = RouteNetwork::build(vec![stations.clone()], ¶ms); + assert!(network.patterns[0].interval_seconds.is_some()); + let calibration: Vec<_> = stations.iter().collect(); + for a in 0..stations.len() { + for b in 0..stations.len() { + if a == b || stations[a].pass == Some(1) || stations[b].pass == Some(1) { + continue; + } + let mut slice: Vec<_> = stations[a.min(b)..=a.max(b)].iter().collect(); + if a > b { + slice.reverse(); + } + let expected = + (estimate_arrival_minutes_with_track(&slice, &calibration, ¶ms, None) + .last() + .unwrap() + .cumulative_minutes + * 60.0) + .round() as i32; + assert_eq!(network.patterns[0].ride_seconds(a, b), expected, "{a}->{b}"); + assert_eq!( + network.search( + stations[a].station_g_cd as u32, + stations[b].station_g_cd as u32, + None + )[0] + .total_seconds, + expected, + "scan {a}->{b}" + ); + } + } + } + + #[test] + fn recommendations_match_exhaustive_leg_relaxation_for_every_station_pair() { + let mut express = vec![ + stop(100, 10000, 1, 0.0, 0.0), + stop(100, 10001, 2, 0.01, 0.0), + stop(100, 10002, 3, 0.02, 0.0), + stop(100, 10003, 4, 0.03, 0.0), + ]; + for s in &mut express { + s.kind = Some(3); + } + express[2].pass = Some(1); + express[2].stop_condition = StopCondition::Not; + let groups = vec![ + express, + vec![ + stop(200, 20000, 1, 0.0, 0.0), + stop(200, 20001, 5, 0.01, 0.01), + stop(200, 20002, 4, 0.03, 0.0), + ], + vec![ + stop(300, 30000, 2, 0.01, 0.0), + stop(300, 30001, 3, 0.02, 0.0), + stop(300, 30002, 6, 0.03, 0.01), + ], + ]; + let params = crate::domain::arrival_estimation::routing_estimation_params(); + let network = RouteNetwork::build(groups.clone(), ¶ms); + // Independent edge oracle: estimate each possible ride on the actual slice, + // then relax all rides, without RAPTOR scans or compiled costs. + let mut edges = Vec::new(); + for stations in &groups { + let calibration: Vec<_> = stations.iter().collect(); + for (a, from) in stations.iter().enumerate() { + for (b, to) in stations.iter().enumerate() { + if a == b || from.pass == Some(1) || to.pass == Some(1) { + continue; + } + let mut slice: Vec<_> = stations[a.min(b)..=a.max(b)].iter().collect(); + if a > b { + slice.reverse(); + } + let ride = + (estimate_arrival_minutes_with_track(&slice, &calibration, ¶ms, None) + .last() + .unwrap() + .cumulative_minutes + * 60.0) + .round() as i64; + edges.push(( + from.station_g_cd as usize, + to.station_g_cd as usize, + ride + i64::from(boarding_wait_seconds(from.kind)), + )); + } + } + } + for from in 1..=6 { + for to in 1..=6 { + if from == to { + continue; + } + let mut previous = vec![UNREACHED; 7]; + previous[from] = 0; + let mut best = UNREACHED; + for rides in 1..=MAX_RIDES { + let mut current = vec![UNREACHED; 7]; + for &(a, b, ride) in &edges { + if previous[a] == UNREACHED { + continue; + } + current[b] = current[b].min( + previous[a] + + ride + + if rides == 1 { + 0 + } else { + i64::from(TRANSFER_WALK_SECONDS) + }, + ); + } + if current[to] != UNREACHED { + best = best.min(current[to] + TRANSFER_RANK_SECONDS * (rides - 1) as i64); + } + previous = current; + } + let journeys = network.search(from as u32, to as u32, None); + let first = journeys.first().expect("reachable pair"); + let board = groups + .iter() + .find(|g| g[0].line_group_cd == Some(first.legs[0].line_group_id as i32)) + .unwrap() + .iter() + .find(|s| s.station_cd == first.legs[0].station_cds[0]) + .unwrap(); + let score = i64::from(first.total_seconds + boarding_wait_seconds(board.kind)) + + TRANSFER_RANK_SECONDS * first.transfer_count() as i64; + assert_eq!(score, best, "{from}->{to}"); + } + } + } + /// 1 分おきに東へ並ぶ駅。`group` が同じ駅は乗換できる。 fn stop(line_group: i32, sst_id: i32, group: i32, lon_step: f64, lat_step: f64) -> Station { Station { @@ -899,6 +1363,68 @@ mod tests { journey.legs.iter().map(|leg| leg.line_group_id).collect() } + #[test] + fn ride_seconds_matches_search_and_rejects_passed_endpoints() { + let mut stops = straight(100, &[(1, 0.0, 0.0), (2, 1.0, 0.0), (3, 2.0, 0.0)]); + stops[1].pass = Some(1); + let network = RouteNetwork::build([stops], &EstimationParams::default()); + for (from, to, from_cd, to_cd) in [(1, 3, 10000, 10002), (3, 1, 10002, 10000)] { + let journey = &network.search(from, to, None)[0]; + assert_eq!( + network.ride_seconds(100, from_cd, from, to_cd, to), + Some((journey.total_seconds, 180)) + ); + // 別路線の駅 ID でも駅グループで引き当てる。 + assert_eq!( + network.ride_seconds(100, -1, from, -2, to), + Some((journey.total_seconds, 180)) + ); + } + assert_eq!(network.ride_seconds(100, 10000, 1, 10001, 2), None); + assert_eq!(network.ride_seconds(999, 10000, 1, 10002, 3), None); + } + + #[test] + fn ride_seconds_preserves_exact_occurrences_and_circular_seams() { + let branch = straight( + 100, + &[ + (1, 0.0, 0.0), + (2, 1.0, 0.0), + (3, 1.0, 1.0), + (1, 0.0, 0.0), + (4, -1.0, 0.0), + ], + ); + let network = RouteNetwork::build([branch], &EstimationParams::default()); + let first = network.ride_seconds(100, 10000, 1, 10004, 4).unwrap().0; + let second = network.ride_seconds(100, 10003, 1, 10004, 4).unwrap().0; + assert!( + first > second, + "exact station IDs distinguish the two junction occurrences" + ); + assert_eq!( + network.ride_seconds(100, -1, 1, 10004, 4).unwrap().0, + second + ); + + let ring = straight( + 200, + &[(1, 0.0, 0.0), (2, 1.0, 0.0), (3, 1.0, 1.0), (4, 0.0, 1.0)], + ); + let network = RouteNetwork::build([ring], &EstimationParams::default()); + for (from, to, from_cd, to_cd) in [(1, 4, 20000, 20003), (4, 1, 20003, 20000)] { + let journey = &network.search(from, to, None)[0]; + assert_eq!( + network + .ride_seconds(200, from_cd, from, to_cd, to) + .unwrap() + .0, + journey.total_seconds + ); + } + } + /// 1 -2 -3 -4 の東西線 (100) と、3 から北へ 5 -6 の南北線 (200)。 fn t_shaped() -> RouteNetwork { RouteNetwork::build( diff --git a/stationapi/src/domain/service_rules.rs b/stationapi/src/domain/service_rules.rs new file mode 100644 index 00000000..e258d75f --- /dev/null +++ b/stationapi/src/domain/service_rules.rs @@ -0,0 +1,142 @@ +//! Explicit operating constraints, independent of route-ranking weights. +use super::entity::station::Station; +use serde::Deserialize; +use std::{collections::HashMap, sync::OnceLock}; +#[derive(Clone, Debug, Deserialize)] +pub struct ServiceRule { + pub type_cd: i32, + pub line_group_id: Option, + pub requires_timetable: bool, + pub boarding_wait_seconds: Option, +} +#[derive(Clone, Debug, Deserialize)] +pub struct StopRule { + pub type_cd: i32, + pub station_group_id: i32, + pub forward_board: bool, + pub forward_alight: bool, + pub reverse_board: bool, + pub reverse_alight: bool, +} +/// Parse canonical service rules. +pub fn parse_service_rules(text: &str) -> Result, csv::Error> { + csv::Reader::from_reader(text.as_bytes()) + .deserialize() + .collect() +} +/// Parse canonical directional stop rules. +pub fn parse_stop_rules(text: &str) -> Result, csv::Error> { + csv::Reader::from_reader(text.as_bytes()) + .deserialize() + .collect() +} +/// Group-specific rules take precedence over type-wide rules. +pub fn service_rule(type_cd: Option, group: Option) -> Option<&'static ServiceRule> { + type Rules = HashMap<(i32, Option), ServiceRule>; + static RULES: OnceLock = OnceLock::new(); + let rules = RULES.get_or_init(|| { + parse_service_rules(include_str!("../../../data/route_service_rules.csv")) + .expect("service rules") + .into_iter() + .map(|r| ((r.type_cd, r.line_group_id), r)) + .collect() + }); + let type_cd = type_cd?; + rules + .get(&(type_cd, group)) + .or_else(|| rules.get(&(type_cd, None))) +} +fn stop_rule(station: &Station) -> Option<&'static StopRule> { + static RULES: OnceLock> = OnceLock::new(); + RULES + .get_or_init(|| { + parse_stop_rules(include_str!("../../../data/route_stop_rules.csv")) + .expect("stop rules") + .into_iter() + .map(|r| ((r.type_cd, r.station_group_id), r)) + .collect() + }) + .get(&(station.type_cd?, station.station_g_cd)) +} +/// Pickup permission in stored or reverse service direction. +pub fn board_allowed(station: &Station, forward: bool) -> bool { + stop_rule(station).is_none_or(|r| { + if forward { + r.forward_board + } else { + r.reverse_board + } + }) +} +/// Drop-off permission in stored or reverse service direction. +pub fn alight_allowed(station: &Station, forward: bool) -> bool { + stop_rule(station).is_none_or(|r| { + if forward { + r.forward_alight + } else { + r.reverse_alight + } + }) +} +/// Wait at the actual boarding station. +pub fn boarding_wait(station: &Station) -> i32 { + service_rule(station.type_cd, station.line_group_cd) + .and_then(|r| r.boarding_wait_seconds) + .unwrap_or_else(|| super::route_search::boarding_wait_seconds(station.kind)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn s_train_rules_cover_the_canonical_weekday_and_holiday_types() { + let mut reader = + csv::Reader::from_reader(include_str!("../../../data/4!types.csv").as_bytes()); + let headers = reader.headers().unwrap().clone(); + let type_column = headers.iter().position(|h| h == "type_cd").unwrap(); + let name_column = headers.iter().position(|h| h == "type_name").unwrap(); + let mut names = Vec::new(); + for record in reader.records() { + let record = record.unwrap(); + let name = &record[name_column]; + if name.starts_with("S-TRAIN") { + let type_cd = record[type_column].parse().unwrap(); + assert!( + service_rule(Some(type_cd), None).is_some_and(|r| r.requires_timetable), + "{name} must require timetable data" + ); + names.push(name.to_owned()); + } + } + assert!(names.iter().any(|name| name.contains("平日"))); + assert!(names.iter().any(|name| name.contains("土休日"))); + } + + #[test] + fn admission_is_specific_to_the_declared_service() { + assert!( + service_rule(Some(316), Some(406)) + .unwrap() + .requires_timetable + ); + assert!( + service_rule(Some(379), Some(188)) + .unwrap() + .requires_timetable + ); + assert!( + service_rule(Some(339), Some(92)) + .unwrap() + .requires_timetable + ); + // 317 is commuter express, not holiday S-TRAIN. The service name alone + // does not justify excluding every commuter express nationwide. + assert!(service_rule(Some(317), None).is_none()); + assert!(service_rule(Some(379), Some(184)).is_none()); + let rule = parse_stop_rules(include_str!("../../../data/route_stop_rules.csv")) + .unwrap() + .remove(0); + assert!(!rule.forward_board && rule.forward_alight); + assert!(!rule.reverse_board && !rule.reverse_alight); + } +} diff --git a/stationapi/src/model.rs b/stationapi/src/model.rs index c4af40ff..fd2a0841 100644 --- a/stationapi/src/model.rs +++ b/stationapi/src/model.rs @@ -315,6 +315,7 @@ pub struct Route { /// 乗換経路探索 (`connectedRoutes`) の経路 1 本。 #[derive(Clone, Debug, Default, PartialEq)] pub struct ConnectedRoute { + pub total_seconds: i32, pub legs: Vec, } @@ -331,6 +332,7 @@ pub struct RouteLegRequest { /// 乗換経路の 1 区間 (1 本の列車)。 #[derive(Clone, Debug, Default, PartialEq)] pub struct RouteLeg { + pub recommended_group_id: u32, /// この区間で乗れる列車種別すべて。`routeTypes(乗車駅グループ, 降車駅グループ, /// 降車駅の路線)` と同じ結果・同じ並び。 pub train_types: Vec, diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index c74b8242..1366c1bd 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -25,7 +25,7 @@ use crate::{ domain::{ arrival_estimation::{ estimate_arrival_minutes_with_track, is_circular_route, select_circular_arc, - track_distance_key, EstimatedStop, EstimationParams, SpeedCalibration, TrackDistances, + track_distance_key, EstimatedStop, EstimationParams, TrackDistances, }, entity::{ company::Company, @@ -1030,12 +1030,10 @@ where to_station_group_id, via_line_id.map(|id| id as i32), ); + route_search::sort_journeys(&mut journeys, sort); if journeys.is_empty() { return Ok(vec![]); } - // 所要時間と乗換回数は API で返さないので、並べ替えはここで済ませる - route_search::sort_journeys(&mut journeys, sort); - // 探索は ID だけを扱うので、乗降駅と種別は経路が確定してからまとめて取得する let station_ids: Vec = journeys .iter() @@ -1082,7 +1080,33 @@ where leg_train_types.insert(key, train_types); } - let routes = journeys + // routeTypes deduplicates equivalent services. Keep the actual evaluated + // group available even when that presentation dedup omitted it. + for leg in journeys.iter().flat_map(|journey| &journey.legs) { + let Some(to_station) = leg.station_cds.last().and_then(|cd| stations.get(cd)) else { + continue; + }; + let key = ( + *leg.station_group_ids.first().unwrap(), + *leg.station_group_ids.last().unwrap(), + to_station.line_cd as u32, + ); + let Some(types) = leg_train_types.get_mut(&key) else { + continue; + }; + if !types.iter().any(|tt| tt.group_id == leg.line_group_id) { + if let Some(preferred) = self + .get_train_types_by_station_id(leg.station_cds[0] as u32) + .await? + .into_iter() + .find(|tt| tt.line_group_cd == Some(leg.line_group_id as i32)) + { + types.push(model::TrainType::from(preferred)); + } + } + } + + let routes: Vec<_> = journeys .into_iter() .filter_map(|journey| { let legs = journey @@ -1096,7 +1120,15 @@ where *leg.station_group_ids.last()?, to_station.line_cd as u32, ); + if !leg_train_types + .get(&key)? + .iter() + .any(|tt| tt.group_id == leg.line_group_id) + { + return None; + } Some(model::RouteLeg { + recommended_group_id: leg.line_group_id, train_types: leg_train_types.get(&key)?.clone(), from_station: model::Station::from(from_station.clone()), to_station: model::Station::from(to_station.clone()), @@ -1105,7 +1137,10 @@ where }) // 駅か種別を引けない区間があれば、その経路は返さない .collect::>>()?; - Some(ConnectedRoute { legs }) + Some(ConnectedRoute { + legs, + total_seconds: journey.total_seconds, + }) }) .collect(); Ok(routes) @@ -1200,9 +1235,13 @@ where // 待ち時間は含めない) 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 wait_minutes = f64::from( + segment + .first() + .and_then(|e| group_stops.iter().find(|s| s.station_cd == e.station_cd)) + .map(|s| crate::domain::service_rules::boarding_wait(s)) + .unwrap_or(0), + ) / 60.0; let arrival = previous.cumulative_minutes + walk_minutes; (arrival, arrival + wait_minutes) } @@ -2102,12 +2141,9 @@ fn estimate_train_route_slice( /// 到着見込み (`estimateArrivalTimes` と `trainRoute` の `Estimated`) の推定の /// パラメータ。駅間の距離に線路の長さを使う前提で求め直した較正を使う。 -/// `connectedRoutes` の所要時間は元の較正 ([`EstimationParams::default`]) のまま。 +/// `connectedRoutes` も同じパラメータを使う。 fn arrival_estimation_params() -> EstimationParams { - EstimationParams { - speed_calibration: SpeedCalibration::Recalibrated, - ..EstimationParams::default() - } + crate::domain::arrival_estimation::routing_estimation_params() } /// `trainRoute` の区間の値を、到着見込み `estimated` のモデルの値に置き換える