From 0edcbb207d7a0384676bf93704d9a8740f7bd613 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 1 Oct 2026 04:33:44 +0900 Subject: [PATCH] =?UTF-8?q?=E7=B7=9A=E8=B7=AF=E3=81=AE=E9=95=B7=E3=81=95?= =?UTF-8?q?=E3=81=A8=E6=B1=82=E3=82=81=E7=9B=B4=E3=81=97=E3=81=9F=E8=BC=83?= =?UTF-8?q?=E6=AD=A3=E3=82=92trainRoute=E3=81=AEEstimated=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=81=A7=E4=BD=BF=E3=81=84=E3=80=81estimateArrivalTim?= =?UTF-8?q?es=E3=81=A8connectedRoutes=E3=82=92=E5=85=83=E3=81=AE=E8=A8=88?= =?UTF-8?q?=E7=AE=97=E3=81=AB=E6=88=BB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- docs/architecture.md | 27 +-- scripts/README.md | 4 +- scripts/travel_time_report.py | 20 ++- src/repository.rs | 50 +++--- src/travel_times.rs | 27 +-- stationapi/src/domain/arrival_estimation.rs | 45 ++++- stationapi/src/domain/legacy_speed_table.rs | 15 +- stationapi/src/domain/segment_speed_table.rs | 3 + stationapi/src/domain/speed_table.rs | 9 +- stationapi/src/use_case/interactor/query.rs | 163 +++++++++++-------- travel_times/README.md | 9 +- 12 files changed, 229 insertions(+), 147 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39922b06..fee7d1de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,14 +71,14 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **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; a bullet-train line, which has no local service, takes the group of its stopping train type with the smallest `types.id`, e.g. Nozomi or Hayabusa) and returns its stops carrying that group's train type, with or without `stationId`, so a client that never picked a train type still has the `lineGroupId` `trainRoute` requires; 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. The optional `model` argument (`TrainRouteModel`) picks how the segment values are computed. `Legacy`, the default, keeps the values from when the query was added (#1568): `dto::simulation::resolve_speed_profile` supplies the speed and acceleration, and `arrivalCumulativeMinutes` / `departureCumulativeMinutes` stay `null`. TrainLCD/MobileApp's auto mode depends on these values, so do not change them; `Legacy` reads frozen copies of the speed tables (`domain/legacy_speed_table.rs`) so that recalibrating the arrival estimation does not move it. `Estimated` runs `arrival_estimation` — the model the speed tables are calibrated against — on the same pre-enrichment slice (with `legs`, it reuses `estimate_connected_route_arrival_times`, so the values equal `estimateArrivalTimes` given the same `legs`). It then replaces each segment's `stops`, speed, and acceleration with the estimator's and fills the two cumulative fields. A route containing a bus station keeps the `Legacy` values, because the estimator's kinematics do not apply to buses. +- **`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. The optional `model` argument (`TrainRouteModel`) picks how the segment values are computed. `Legacy`, the default, keeps the values from when the query was added (#1568): `dto::simulation::resolve_speed_profile` supplies the speed and acceleration, and `arrivalCumulativeMinutes` / `departureCumulativeMinutes` stay `null`. TrainLCD/MobileApp's auto mode depends on these values, so do not change them; `Legacy` reads frozen copies of the speed tables (`domain/legacy_speed_table.rs`) so that recalibrating `Estimated` does not move it. `Estimated` runs `arrival_estimation` — the model the speed tables are calibrated against — on the same pre-enrichment slice (with `legs`, it reuses `estimate_connected_route_arrival_times`, so the values equal `estimateArrivalTimes` given the same `legs`). It then replaces each segment's `stops`, speed, and acceleration with the estimator's and fills the two cumulative fields. A route containing a bus station keeps the `Legacy` values, because the estimator's kinematics do not apply to buses. - **Coordinate lookups** – `index::nearest` (k nearest, used by `stationsNearby`) and `index::within_radius` (everything inside a radius, used by the nearby-bus-stop enrichment) both go through a per-transport-type grid index (`Grid`, CSR over 0.05° cells) instead of scanning the whole station table. `nearest` searches a radius, widens it while fewer than `limit` stations fall inside, and stops once the radius covers the index — anything outside a radius that already holds `limit` hits cannot be in the top `limit`. With `transportType` omitted it returns rail stations first and bus stops after, each group sorted by distance — the pre-Workers SQL's `ORDER BY transport_type, distance`. The limit applies to the merged order, so `nearest` fills it with rail and only asks the bus grid for the remaining slots; a location with `limit` rail stations returns no bus stops at all. Ties on distance break on `station_cd` so the order does not depend on an unstable sort. Every station lookup by coordinates runs on every request that enriches rail stations with nearby bus routes, so keep new coordinate queries on the grid rather than adding another full scan. - **Train types** – `stationTrainTypes`, `routeTypes`. Train types aggregate by line group and include related lines plus optional train type metadata. Rail variants use `TrainTypeKind::{Default, Branch, Rapid, Express, LimitedExpress, HighSpeedRapid, CommuterRapid}` (0-6); bus variants use `BusRoute` (7), which represents a `(route_id, shape_id)` operation pattern (e.g. 循環 / 短ターン / 支線) generated automatically from the configured GTFS bus feeds (Toei Bus, Seibu Bus, Keio Bus) and the converted Tokyu Bus JSON. - **Default rail train types** – `preprocessor` fills every active rail line containing at least one station with no `station_station_types` row with a deterministic, complete all-stop group. The generated rows exist only in `generated/*.csv`; canonical CSV files remain unchanged. `type_cd=100` represents 「普通」 and `type_cd=101` represents 「各駅停車」. An existing 100/101 assignment on the line takes precedence; otherwise the label is selected per line through `LOCAL_SERVICE_RAIL_LINE_IDS` in `preprocessor/src/rail.rs`. Generated `line_group_cd` values use `1,000,000,000 + line_cd`; generation fails on a collision. Bus lines are excluded and continue to use their GTFS-derived `BusRoute` groups. - **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`, `trainRoute` with `Estimated`, and the ride times of `connectedRoutes`) uses the track length as the running distance wherever one exists and falls back to straight line × detour factor elsewhere; `scripts/compute_speed_table.py` fits the speed tables with the same distances, so it needs `generated/` from `make data`. 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. Only `trainRoute` with `Estimated` (used by TrainLCD/MobileApp's GPX generation) 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`. Everything else — `estimateArrivalTimes`, the ride times of `connectedRoutes`, and `trainRoute` with `Legacy` — keeps the original estimation (straight line × detour factor and the frozen tables in `domain/legacy_speed_table.rs`, the `EstimationParams` default), so recalibrating never moves ETAs, route search results, or auto mode. 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 estimated time `estimateArrivalTimes` also reports — excluding the first train's wait — 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). - 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. diff --git a/docs/architecture.md b/docs/architecture.md index 698fe482..cf821dd8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -484,17 +484,20 @@ input RouteLegInput { lineGroupId: Int! fromStationId: Int! toStationId: Int! MobileApp のオートモードがこの値で走るので、値を変えません。速度の較正は、 到着時間推定の較正を求め直す前の表を `domain/legacy_speed_table.rs` に凍結して 使います。 -- `Estimated`: 到着時間推定 (`arrival_estimation`) のモデルです。速度の較正 - テーブルは、このモデルに当てはめて求めています。返す駅列に推定を掛け、停車・ - 通過、最高速度、加減速を推定が使った値に置き換えて、到着・出発の見込みを入れ - ます。`legs` を渡したときの見込みは、同じ `legs` を渡した - `estimateArrivalTimes` と同じ値です。バスの駅を含む経路は推定のモデルの対象外 - なので、`Legacy` と同じ値を返します。 +- `Estimated`: 到着時間推定 (`arrival_estimation`) のモデルで、MobileApp の GPX の + 生成が使います。返す駅列に推定を掛け、停車・通過、最高速度、加減速を推定が + 使った値に置き換えて、到着・出発の見込みを入れます。`legs` を渡したときの見込みは、 + 同じ `legs` を渡した `estimateArrivalTimes` と同じ組み立て方 (乗換の徒歩と待ち + 時間) ですが、較正と駅間の距離が違うので値は一致しません (下記)。バスの駅を含む + 経路は推定のモデルの対象外なので、`Legacy` と同じ値を返します。 2 つのモデルは、加減速、運転余裕率、停車時間、較正テーブル、駅間の距離が違い -ます。到着時間推定は、線路の長さ (`connections`) がある駅間ではそれを走行距離に -使い、無い駅間だけ直線距離 × 迂回係数で見積もります。乗換経路探索 -(`connectedRoutes`) の所要時間も同じ距離で求めます。`Legacy` の値で台形の速度プロファイルを作って走らせると、 +ます。`Estimated` は、線路の長さ (`connections`) がある駅間ではそれを走行距離に +使い、無い駅間だけ直線距離 × 迂回係数で見積もり、その距離で求め直した較正 +(`speed_table` / `segment_speed_table`) を使います。この求め直した計算を使うのは +`Estimated` だけです。`estimateArrivalTimes` と乗換経路探索 (`connectedRoutes`) は、 +元の計算 (直線距離 × 迂回係数と、`domain/legacy_speed_table.rs` の元の較正) の +ままで、ETA と経路検索の結果は変わりません。`Legacy` の値で台形の速度プロファイルを作って走らせると、 `estimateArrivalTimes` より短い時間で走り切ります (#1709)。所要時間を推定に 合わせたいクライアントは、`Estimated` の見込みを使います。 @@ -512,8 +515,8 @@ input RouteLegInput { lineGroupId: Int! fromStationId: Int! toStationId: Int! ### 所要時間のベンチマーク (`travel_times/`) -到着時間推定の所要時間を、実際の列車の所要時間と比べる基準を `travel_times/cases.csv` -に置いています。速度の較正テーブルや一般則は、1 つの路線に合わせて変えると、同じ +`trainRoute` の `Estimated` の所要時間を、実際の列車の所要時間と比べる基準を +`travel_times/cases.csv` に置いています。速度の較正テーブルや一般則は、1 つの路線に合わせて変えると、同じ 規則を使うほかの路線の推定も変わります。変更の前後で全体の誤差を測るための仕組み です。 @@ -764,7 +767,7 @@ repository の実装がないメソッドは、空の結果ではなく `DomainE │ │ ├── entity/ # Station / Line / TrainType / Company ... │ │ ├── repository/ # 抽象インターフェース │ │ ├── arrival_estimation.rs -│ │ ├── legacy_speed_table.rs # trainRoute の Legacy (オートモード) が使う凍結した較正 +│ │ ├── legacy_speed_table.rs # 元の較正 (Estimated 以外のすべてが使う) │ │ ├── route_search.rs # 乗換経路探索 (RAPTOR) │ │ ├── route_topology.rs # 所要時間を持たない系統網 (stationsByName の到達判定) │ │ ├── segment_speed_table.rs diff --git a/scripts/README.md b/scripts/README.md index 3b45211d..6c05e844 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -48,7 +48,9 @@ OSM データは [Open Database License (ODbL)](https://www.openstreetmap.org/co ## compute_speed_table.py 公開 GTFS 時刻表から、到着時間推定(`arrival_estimation.rs`)の速度較正テーブル -2 種類を再計算します。運動学モデルを Python で再現し、実ダイヤの所要時間を +2 種類を再計算します。このテーブルを使うのは `trainRoute` の `Estimated` だけです +(`estimateArrivalTimes` と `connectedRoutes` は `legacy_speed_table.rs` の元の較正を +使います)。運動学モデルを Python で再現し、実ダイヤの所要時間を 再現する実効最高速度を二分探索でフィッティングします。 駅間の距離は、推定と同じく線路の長さ(`generated/connections.csv`)を使い、無い diff --git a/scripts/travel_time_report.py b/scripts/travel_time_report.py index dd9fc437..db13b74a 100644 --- a/scripts/travel_time_report.py +++ b/scripts/travel_time_report.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""実際の所要時間 (travel_times/cases.csv) に対する到着時間推定の誤差を、動いている -Worker に問い合わせて Markdown で出す。 +"""実際の所要時間 (travel_times/cases.csv) に対する trainRoute の Estimated (MobileApp の +GPX の生成が使う推定) の誤差を、動いている Worker に問い合わせて Markdown で出す。 CI の回帰テスト (src/travel_times.rs) は data/*.csv だけで動くので、生成データにしか 無い種別グループを飛ばし、線路の長さも持たない。本番と同じ生成データでの精度は、 @@ -25,9 +25,9 @@ # 既定の Python-urllib は配信側で弾かれるので、bench.py と同じく名乗る USER_AGENT = "stationapi-travel-time-report/1.0 (+https://github.com/TrainLCD/StationAPI)" QUERY = """query TravelTimeReport($from: Int!, $to: Int!, $group: Int!) { - estimateArrivalTimes(fromStationId: $from, toStationId: $to, + trainRoute(fromStationId: $from, toStationId: $to, model: Estimated, legs: [{ lineGroupId: $group, fromStationId: $from, toStationId: $to }]) { - routes { stops { stationId cumulativeMinutes departureCumulativeMinutes } } + segments { station { id } arrivalCumulativeMinutes departureCumulativeMinutes } } }""" @@ -49,11 +49,15 @@ def estimate(api: str, case: dict) -> float: data = json.load(res) if data.get("errors"): raise RuntimeError("; ".join(e.get("message", "") for e in data["errors"])) - stops = data["data"]["estimateArrivalTimes"]["routes"][0]["stops"] + segments = data["data"]["trainRoute"]["segments"] target = int(case["to_station_id"]) - stop = next(s for s in stops[1:] if s["stationId"] == target) - key = "cumulativeMinutes" if case["measure"] == "arrival" else "departureCumulativeMinutes" - return float(stop[key]) + segment = next(s for s in segments[1:] if s["station"]["id"] == target) + key = ( + "arrivalCumulativeMinutes" + if case["measure"] == "arrival" + else "departureCumulativeMinutes" + ) + return float(segment[key]) def range_error(est: float, lo: float, hi: float) -> float: diff --git a/src/repository.rs b/src/repository.rs index 2f82aa0c..3171df5e 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -158,10 +158,9 @@ fn route_network() -> &'static Arc { } fn build_route_network() -> RouteNetwork { - RouteNetwork::build_with_track( + RouteNetwork::build( rail_line_group_cds().map(|group| stations_of_line_groups(&[group as u32])), &EstimationParams::default(), - index::track_distance, ) } @@ -1667,9 +1666,11 @@ mod tests { assert_eq!(route_ids, ids); } - /// Estimated の trainRoute は、同じ legs を渡した estimateArrivalTimes と同じ - /// 見込みを返し、停車・加減速も推定のモデルにそろう。Legacy は見込みを返さず、 - /// 区間の値も変わらない。 + /// Estimated の trainRoute は、同じ legs を渡した estimateArrivalTimes と同じ駅を + /// 同じ組み立て方 (乗換の徒歩と待ち時間) でつないだ見込みを返し、停車・加減速も + /// 推定のモデルにそろう。値は Estimated だけが求め直した較正と線路の長さを使う + /// ので、estimateArrivalTimes とは一致しない。Legacy は見込みを返さず、区間の値も + /// 変わらない。 #[test] fn estimated_connected_train_route_carries_the_estimate_arrival_times() { use stationapi::use_case::traits::query::QueryUseCase; @@ -1689,17 +1690,17 @@ mod tests { .unwrap(); assert_eq!(estimated.len(), eta.len()); + let mut previous_departure = 0.0; for (i, (segment, stop)) in estimated.iter().zip(&eta).enumerate() { assert_eq!( - segment.arrival_cumulative_minutes, - Some(stop.cumulative_minutes) - ); - assert_eq!( - segment.departure_cumulative_minutes, - Some(stop.departure_cumulative_minutes) + segment.station.as_ref().map(|station| station.id as i32), + Some(stop.station_cd) ); + let arrival = segment.arrival_cumulative_minutes.unwrap(); + let departure = segment.departure_cumulative_minutes.unwrap(); + assert!(arrival >= previous_departure - 1e-9 && departure >= arrival); + previous_departure = departure; assert_eq!(segment.stops, stop.stops_here); - assert_eq!(segment.max_speed, stop.max_speed_kmh / 3.6); assert_eq!(segment.max_acceleration, params.accel); assert_eq!(segment.max_deceleration, params.decel); // 駅と距離は Legacy と同じ @@ -1718,7 +1719,7 @@ mod tests { } /// lineGroupId で呼んだ Estimated の trainRoute は、その区間を 1 つの leg にした - /// estimateArrivalTimes と同じ見込みになる (環状線でない系統では同じ駅列を + /// Estimated の trainRoute と同じ見込みになる (環状線でない系統では同じ駅列を /// 同じ較正母数で推定するため)。#1709 の 2 経路で確かめる。 #[test] fn estimated_train_route_matches_a_single_leg_estimate() { @@ -1733,27 +1734,26 @@ mod tests { TrainRouteModel::Estimated, )) .unwrap(); - let eta = block_on(interactor.estimate_connected_route_arrival_times(&[ - model::RouteLegRequest { + let single_leg = block_on(interactor.get_connected_train_route( + &[model::RouteLegRequest { line_group_id, from_station_id: from, to_station_id: to, - }, - ])) + }], + TrainRouteModel::Estimated, + )) .unwrap(); - assert_eq!(segments.len(), eta.len()); - for (segment, stop) in segments.iter().zip(&eta) { - assert_eq!( - segment.station.as_ref().map(|station| station.id as i32), - Some(stop.station_cd) - ); + assert_eq!(segments.len(), single_leg.len()); + for (segment, leg_segment) in segments.iter().zip(&single_leg) { + assert_eq!(segment.station, leg_segment.station); + assert!(segment.arrival_cumulative_minutes.is_some()); assert_eq!( segment.arrival_cumulative_minutes, - Some(stop.cumulative_minutes) + leg_segment.arrival_cumulative_minutes ); assert_eq!( segment.departure_cumulative_minutes, - Some(stop.departure_cumulative_minutes) + leg_segment.departure_cumulative_minutes ); } } diff --git a/src/travel_times.rs b/src/travel_times.rs index 1c6aeff3..231ca0c1 100644 --- a/src/travel_times.rs +++ b/src/travel_times.rs @@ -1,4 +1,6 @@ -//! 実際の所要時間 (`travel_times/cases.csv`) に対する到着時間推定の回帰の見張り。 +//! 実際の所要時間 (`travel_times/cases.csv`) に対する `trainRoute` の `Estimated` +//! (MobileApp の GPX の生成が使う推定) の回帰の見張り。`estimateArrivalTimes` と +//! `connectedRoutes` は元の較正のままなので、ここでは測らない。 //! //! 基準ごとに推定の所要時間を出し、実際の典型的な所要時間 (平日日中の中央値) から //! のずれを求める。記録した推定 (`travel_times/baseline.csv`) より悪くなった基準が @@ -21,7 +23,7 @@ use std::collections::HashMap; use stationapi::domain::repository::station_repository::StationRepository; -use stationapi::model::RouteLegRequest; +use stationapi::model::{RouteLegRequest, TrainRouteModel}; use stationapi::use_case::traits::query::QueryUseCase; use crate::repository::MemStationRepository; @@ -154,17 +156,22 @@ fn estimate(case: &Case) -> Option { from_station_id: case.from_station_id, to_station_id: case.slice_end_station_id, }]; - let stops = block_on(crate::interactor().estimate_connected_route_arrival_times(&legs)) - .unwrap_or_else(|e| panic!("{}: 推定できない: {e}", case.label)); - let stop = stops + // 見張るのは trainRoute の Estimated (GPX の生成が使う推定)。estimateArrivalTimes と + // connectedRoutes は元の較正のままで、推定の規則や較正を変えても動かない + let segments = + block_on(crate::interactor().get_connected_train_route(&legs, TrainRouteModel::Estimated)) + .unwrap_or_else(|e| panic!("{}: 推定できない: {e}", case.label)); + let segment = segments .iter() .skip(1) - .find(|stop| stop.station_cd as u32 == case.to_station_id) + .find(|segment| { + segment.station.as_ref().map(|station| station.id) == Some(case.to_station_id) + }) .unwrap_or_else(|| panic!("{}: 推定の駅列に到着駅が無い", case.label)); - Some(match case.measure { - Measure::Arrival => stop.cumulative_minutes, - Measure::Departure => stop.departure_cumulative_minutes, - }) + match case.measure { + Measure::Arrival => segment.arrival_cumulative_minutes, + Measure::Departure => segment.departure_cumulative_minutes, + } } fn read_baseline() -> HashMap { diff --git a/stationapi/src/domain/arrival_estimation.rs b/stationapi/src/domain/arrival_estimation.rs index 087f1905..1f732f60 100644 --- a/stationapi/src/domain/arrival_estimation.rs +++ b/stationapi/src/domain/arrival_estimation.rs @@ -42,6 +42,9 @@ use std::collections::HashMap; use crate::domain::entity::gtfs::TransportType; use crate::domain::entity::station::Station; +use crate::domain::legacy_speed_table::{ + legacy_line_speed_override_kmh, legacy_segment_speed_override_kmh, +}; use crate::domain::segment_speed_table::{ segment_override_applies_to_kind, segment_speed_override_kmh, }; @@ -72,6 +75,19 @@ pub struct EstimatedStop { pub max_speed_kmh: f64, } +/// 速度の較正にどの表を使うか。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SpeedCalibration { + /// 元の較正 (`legacy_speed_table`。#1712 で求め直す前の表)。`estimateArrivalTimes`、 + /// `connectedRoutes` など、`trainRoute` の `Estimated` 以外はすべてこれを使う。 + #[default] + Original, + /// 駅間の距離に線路の長さを使う前提で求め直した較正 (`speed_table` / + /// `segment_speed_table`)。`trainRoute` の `Estimated` (MobileApp の GPX の生成) + /// だけが使う。 + Recalibrated, +} + /// 推定で使う調整可能なパラメータ。すべて「実距離・実速度・ダイヤが無い」前提の /// ヒューリスティックであり、後から較正・上書きできるよう一箇所に集約する。 #[derive(Clone, Copy, Debug)] @@ -93,6 +109,8 @@ pub struct EstimationParams { pub detour_min: f64, /// 迂回係数 `α` のクランプ上限。 pub detour_max: f64, + /// 速度の較正にどの表を使うか。 + pub speed_calibration: SpeedCalibration, } impl Default for EstimationParams { @@ -105,6 +123,7 @@ impl Default for EstimationParams { pass_penalty_seconds: 3.0, detour_min: 1.0, detour_max: 1.6, + speed_calibration: SpeedCalibration::Original, } } } @@ -367,11 +386,16 @@ fn max_speed_kmh( line_type: Option, kind: Option, transport_type: TransportType, + calibration: SpeedCalibration, ) -> f64 { if transport_type == TransportType::Bus { return BUS_MAX_SPEED_KMH; } - if let Some(v) = line_speed_override_kmh(line_cd, kind) { + let calibrated = match calibration { + SpeedCalibration::Original => legacy_line_speed_override_kmh(line_cd, kind), + SpeedCalibration::Recalibrated => line_speed_override_kmh(line_cd, kind), + }; + if let Some(v) = calibrated { return v; } let base = base_speed_kmh(line_type); @@ -658,19 +682,26 @@ pub fn estimate_arrival_minutes_with_track( stops[i].line_type, effective_kind, stops[i].transport_type, + params.speed_calibration, ); // 隣接駅ペア単位の較正(GTFS 実ダイヤ由来)があれば路線単位の速度より // 優先する。急曲線・急勾配で路線平均より遅い区間(大江戸線 月島〜赤羽橋 // など)の区間差を反映する。各停系種別の鉄道のみ。 - if i > 0 - && stops[i].transport_type != TransportType::Bus - && segment_override_applies_to_kind(effective_kind) - { - if let Some(v) = segment_speed_override_kmh( + if i > 0 && stops[i].transport_type != TransportType::Bus { + let (line_cd, a, b) = ( stops[i].line_cd, stops[i - 1].station_cd, stops[i].station_cd, - ) { + ); + let calibrated = match params.speed_calibration { + SpeedCalibration::Original => { + legacy_segment_speed_override_kmh(line_cd, a, b, effective_kind) + } + SpeedCalibration::Recalibrated => segment_override_applies_to_kind(effective_kind) + .then(|| segment_speed_override_kmh(line_cd, a, b)) + .flatten(), + }; + if let Some(v) = calibrated { v_kmh = v; } } diff --git a/stationapi/src/domain/legacy_speed_table.rs b/stationapi/src/domain/legacy_speed_table.rs index c3e395c0..9a1a5221 100644 --- a/stationapi/src/domain/legacy_speed_table.rs +++ b/stationapi/src/domain/legacy_speed_table.rs @@ -1,10 +1,13 @@ -//! `trainRoute` の `Legacy` モデル (MobileApp のオートモード) が使う速度の較正テーブル。 +//! 元の速度の較正テーブル。`trainRoute` の `Estimated` 以外のすべて +//! (`estimateArrivalTimes`、`connectedRoutes`、`trainRoute` の `Legacy` = MobileApp の +//! オートモード) が使う。 //! -//! 到着時間推定の較正 (`speed_table` / `segment_speed_table`) は、距離の出し方を -//! 線路の長さへ替えたときに求め直した。`Legacy` はオートモードの走り方を変えない -//! ために、求め直す前の表 (TrainLCD/StationAPI の `dev`、#1711 の時点) をここに -//! 凍結して持つ。`scripts/compute_speed_table.py` はこのファイルを書き換えない。 -//! 値を変えるとオートモードの速度が変わるので、変えないこと。 +//! `speed_table` / `segment_speed_table` は、駅間の距離に線路の長さを使う前提で +//! 求め直した (#1712)。求め直した較正は `trainRoute` の `Estimated` (MobileApp の GPX の +//! 生成) だけが使い、ほかの推定の値が変わらないよう、求め直す前の表 +//! (TrainLCD/StationAPI の `dev`、#1711 の時点) をここに凍結して持つ。 +//! `scripts/compute_speed_table.py` はこのファイルを書き換えない。値を変えると +//! ETA・経路検索・オートモードが変わるので、変えないこと。 use crate::domain::segment_speed_table::segment_override_applies_to_kind; use crate::model::TrainTypeKind; diff --git a/stationapi/src/domain/segment_speed_table.rs b/stationapi/src/domain/segment_speed_table.rs index b917e993..a29765b4 100644 --- a/stationapi/src/domain/segment_speed_table.rs +++ b/stationapi/src/domain/segment_speed_table.rs @@ -6,6 +6,9 @@ //! この残差を原理的に解消できないため、公開 GTFS 時刻表から隣接駅ペア単位で //! フィットした実効速度をここで上書きする。 //! +//! `trainRoute` の `Estimated` の推定 (`SpeedCalibration::Recalibrated`) だけが +//! 参照する。ほかの推定は、求め直す前の表 (`legacy_speed_table`) を使う。 +//! //! 生成は `scripts/compute_speed_table.py --apply`(路線単位テーブルと同時生成)。 //! 駅間の純走行時間(次駅の到着時刻が別記録されていれば「出発 → 次駅到着」、 //! 無ければ「出発 → 次駅出発 − モデル停車時分」)を複数本の平均でデクオンタイズし、 diff --git a/stationapi/src/domain/speed_table.rs b/stationapi/src/domain/speed_table.rs index 0bc23528..a357dfe2 100644 --- a/stationapi/src/domain/speed_table.rs +++ b/stationapi/src/domain/speed_table.rs @@ -9,8 +9,9 @@ //! //! 値の意味は「その路線・種別での実効巡航速度(km/h)」。理論上の車両性能では //! なく、時刻表所要時間を運動学モデルで再現する値として較正している。 -//! 到着時間推定と GetTrainRoute の速度プロファイル(`resolve_speed_profile`)の -//! 両方から参照される。 +//! `trainRoute` の `Estimated` の推定 (`SpeedCalibration::Recalibrated`) だけが +//! 参照する。`estimateArrivalTimes`・`connectedRoutes`・`trainRoute` の `Legacy` は、 +//! 求め直す前の表 (`legacy_speed_table`) を使う。 //! //! エントリ追加の指針: //! - 公表運転速度(例: 京急快特 120km/h、スカイライナー 160km/h)を起点にし、 @@ -32,8 +33,8 @@ use crate::model::TrainTypeKind; /// 距離が直線 × 迂回係数だった頃の値は、距離の水増しを速度で打ち消していたので、 /// 線路の長さへ替えたときに求め直した。小田急線 (快速急行) は一般則で典型値に /// 近づいたので外した。つくばエクスプレスと都営大江戸線は GTFS の自動較正に任せる。 -/// オートモード (`trainRoute` の `Legacy`) は求め直す前の値を `legacy_speed_table` -/// で使い続ける。 +/// `estimateArrivalTimes`・`connectedRoutes`・`trainRoute` の `Legacy` は、求め直す前の +/// 値を `legacy_speed_table` で使い続ける。 const LINE_SPEED_OVERRIDES: &[(i32, TrainTypeKind, f64)] = &[ // 総武快速線: 最高 130km/h の別線を走る。StationAPI の路線には快速の停車駅しか // 無く、通過駅が無いので推定は各停 (Default) として扱う。一般則の 80km/h では diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index b745517c..4ac221e9 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, TrackDistances, + track_distance_key, EstimatedStop, EstimationParams, SpeedCalibration, TrackDistances, }, entity::{ company::Company, @@ -947,60 +947,7 @@ where &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 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 track = self.track_distances_of(&group_stops).await?; - 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, - Some(&track), - ) - .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), - }; - 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); - } - } - Ok(result) + self.estimate_legs(legs, false).await } async fn get_connected_train_route( @@ -1035,10 +982,11 @@ where // 区間ごとに別の列車なので、通過駅の有無や距離の起点も区間ごとに数える segments.extend(self.train_route_segments(sliced, leg.line_group_id).await?); } - // 見込みは estimateArrivalTimes に同じ legs を渡したときと同じ値にする - // (乗換の徒歩と待ち時間の見込みも含む)。バスは推定のモデルの対象外 + // 見込みは estimateArrivalTimes に同じ legs を渡したときと同じ組み立て方 + // (乗換の徒歩と待ち時間の見込みも含む) で、較正と駅間の距離だけを Estimated の + // もの (求め直した較正と線路の長さ) にする。バスは推定のモデルの対象外 if estimated_model && !has_bus { - let estimated = self.estimate_connected_route_arrival_times(legs).await?; + let estimated = self.estimate_legs(legs, true).await?; apply_estimated_model(&mut segments, &estimated)?; } Ok(segments) @@ -1189,13 +1137,12 @@ where let mut result: Vec = Vec::new(); for group_stops in route_row_tree_map.values() { - let track = self.track_distances_of(group_stops).await?; if let Some(segment) = estimate_group_segment( group_stops, SegmentEndpoints::exact(from_station_id, to_station_id), direction_id.is_some(), ¶ms, - Some(&track), + None, ) { result.extend(segment); } @@ -1212,6 +1159,79 @@ where TR: TrainTypeRepository, CR: CompanyRepository, { + /// 区間 (`legs`) をつないだ経路の到着見込み。`estimated_model` が真なら + /// `trainRoute` の `Estimated` 用で、求め直した較正と線路の長さで推定する。 + /// 偽なら `estimateArrivalTimes` 用で、元の較正と直線距離 × 迂回係数で推定する + /// (`connectedRoutes` の所要時間と同じ見積もり)。 + async fn estimate_legs( + &self, + legs: &[model::RouteLegRequest], + estimated_model: bool, + ) -> Result, UseCaseError> { + let group_of = self.validate_route_legs(legs).await?; + let params = if estimated_model { + estimated_model_params() + } else { + EstimationParams::default() + }; + let walk_minutes = f64::from(route_search::TRANSFER_WALK_SECONDS) / 60.0; + + 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 track = if estimated_model { + Some(self.track_distances_of(&group_stops).await?) + } else { + None + }; + 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, + track.as_ref(), + ) + .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), + }; + 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); + } + } + Ok(result) + } + /// 乗換経路の区間の並びが 1 本の経路としてつながっているか確かめる。 /// 前の区間の降車駅と次の区間の乗車駅は同じ駅グループでなければならない。 /// 返り値は、区間の乗降駅の `station_cd` -> 駅グループ。 @@ -2085,11 +2105,21 @@ fn estimate_train_route_slice( Some(estimate_arrival_minutes_with_track( &stops, &route, - &EstimationParams::default(), + &estimated_model_params(), Some(track), )) } +/// `trainRoute` の `Estimated` だけが使う推定のパラメータ。駅間の距離に線路の長さを +/// 使う前提で求め直した較正を使う。ほかの推定 (`estimateArrivalTimes`、 +/// `connectedRoutes`) は元の較正 ([`EstimationParams::default`]) のまま。 +fn estimated_model_params() -> EstimationParams { + EstimationParams { + speed_calibration: SpeedCalibration::Recalibrated, + ..EstimationParams::default() + } +} + /// `trainRoute` の区間の値を、到着見込み `estimated` のモデルの値に置き換える /// (`TrainRouteModel::Estimated`)。 /// @@ -3831,12 +3861,9 @@ mod tests { .push(station.clone()); } } - // get_track_distances と同じ長さで組み立て、見込みと探索の所要時間をそろえる - let distance = self.track_distance; - Ok(std::sync::Arc::new(RouteNetwork::build_with_track( + Ok(std::sync::Arc::new(RouteNetwork::build( by_line_group.into_values(), &EstimationParams::default(), - move |_, _| distance, ))) } async fn get_track_distances( @@ -6462,8 +6489,8 @@ mod tests { .all(|s| s.max_acceleration == 0.83 && s.max_deceleration == 0.69)); } - /// Estimated は、同じ駅列を系統全体で較正し、系統の線路の長さを渡した - /// 到着時間推定の値を返す。 + /// Estimated は、同じ駅列を系統全体で較正し、系統の線路の長さと求め直した + /// 較正 (`SpeedCalibration::Recalibrated`) を渡した到着時間推定の値を返す。 #[tokio::test] async fn estimated_model_follows_the_arrival_estimation() { let group = build_line_group(20); @@ -6475,7 +6502,7 @@ mod tests { let slice: Vec<&Station> = group[2..=6].iter().collect(); let whole: Vec<&Station> = group.iter().collect(); - let params = EstimationParams::default(); + let params = estimated_model_params(); let track = interactor.track_distances_of(&whole).await.unwrap(); assert!( !track.is_empty(), diff --git a/travel_times/README.md b/travel_times/README.md index f902f420..0b214027 100644 --- a/travel_times/README.md +++ b/travel_times/README.md @@ -1,7 +1,8 @@ # travel_times/ -到着時間推定 (`stationapi/src/domain/arrival_estimation.rs`) の所要時間を、実際の -列車の所要時間と比べるための基準を置く場所です。速度の較正テーブルや一般則は、 +`trainRoute` の `Estimated` (MobileApp の GPX の生成が使う到着時間推定) の所要時間を、 +実際の列車の所要時間と比べるための基準を置く場所です。`estimateArrivalTimes` と +`connectedRoutes` は元の較正のままなので、ここでは測りません。速度の較正テーブルや一般則は、 1 つの路線に合わせて変えると、同じ規則を使うほかの路線の推定も変わります。 変更の前後で全体の誤差を測り、局所的な合わせ込みで全体が崩れないようにします。 @@ -50,7 +51,7 @@ も表に出しますが、判定には使いません。記録にある基準を 推定できなくなったとき (種別グループがデータから消えたときなど) も失敗します。 -比べるのは、本番と同じ生成データ (`generated/`) で動くときだけです。到着時間推定は、 +比べるのは、本番と同じ生成データ (`generated/`) で動くときだけです。`Estimated` は、 生成データにしか無い線路の長さや種別グループを使うので、`data/*.csv` だけでは本番の 推定を再現できません。CI では `build_worker.yml` が `generated/` を作ってから走らせ ます。`data/*.csv` で動くとき (`ci.yml` のテストなど) は、表を出すだけにします。 @@ -65,7 +66,7 @@ TRAVEL_TIMES_UPDATE_BASELINE=1 cargo test -p stationapi-worker travel_times ### レポート (本番と同じデータでの精度) -動いている Worker に `estimateArrivalTimes` を問い合わせ、全件の誤差を Markdown で +動いている Worker に `trainRoute` (`model: Estimated`) を問い合わせ、全件の誤差を Markdown で 出します。`make data && make dev` で起動した Worker (既定) か、ステージングに 向けます。