diff --git a/AGENTS.md b/AGENTS.md index 7a17ad26..63b59823 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ This guide explains how automation agents and human contributors should work with the StationAPI repository so releases stay predictable, auditable, and safe. Update this file whenever you change the workflow or behavior it documents. ## Project Layout -- `src/` – The Worker itself (`stationapi-worker`, wasm32 only). `lib.rs` holds the endpoints, `index.rs` parses the embedded CSVs into in-memory indexes, `repository.rs` implements the repository traits against those indexes, and `graphql/` holds the async-graphql types and resolvers. +- `src/` – The Worker itself (`stationapi-worker`, wasm32 only). `lib.rs` holds the endpoints, `index.rs` parses the embedded CSVs into in-memory indexes, `repository.rs` implements the repository traits against those indexes, and `graphql/` holds the async-graphql types and resolvers. `index.rs` also holds the spatial grid used by every coordinate lookup — see **Coordinate lookups** below. - `schema/public.graphql` – The published GraphQL schema. CI diffs the Worker's SDL against this file, so an unintended change fails the build. - `build.rs` – Stages `generated/*.csv` (falling back to `data/*.csv`) into `OUT_DIR` and pre-converts `station_station_types` into a fixed-width binary. - `wrangler.jsonc` – Staging and production deployment settings. @@ -56,7 +56,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - `data_validator` currently verifies that `5!station_station_types.csv` references valid station and type IDs, and that order-sensitive station sequences in `3!stations.csv` stay intact under `ORDER BY e_sort, station_cd` (e.g. the Toei Oedo Line's Tochomae rows, whose misordering silently drops the station from ETA estimation). Extend the validator when new cross-references or order-sensitive spots are introduced and keep the process fail-fast (panic on invalid data). ## Testing and Quality -- **Tests** – `make test` runs the unit tests for every native crate. They need no external services. +- **Tests** – `make test` runs the unit tests for every native crate, plus `cargo test -p stationapi-worker`. The Worker only *runs* on Workers, but `src/index.rs` is a pure in-memory data structure that builds and executes natively, so its tests (including the grid-versus-full-scan differential check) run here. They need no external services. - **Type checks** – `make check` covers the native crates and the wasm32 target separately. The Worker also compiles for the host, but only runs on Workers. - **Linting and formatting** – `make fmt` and `make clippy` before committing (clippy covers the wasm32 target too). Resolve new Clippy warnings unless an existing `#![allow]` covers the case. - **Schema** – Changing a GraphQL type changes the SDL. Update `schema/public.graphql` in the same change; CI compares it against the running Worker's `/__schema` and fails on any difference. That diff is exactly the client-visible impact. @@ -67,6 +67,8 @@ 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); 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. - **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. +- **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`. It orders purely by distance: `stationsNearby` is specified as nearest-first, so rail and bus are not separated even when `transportType` is omitted (the pre-Workers SQL sorted on `transport_type` first, which put a rail station kilometres away ahead of a bus stop at the same address). 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`. diff --git a/Makefile b/Makefile index 854b1697..0067c999 100644 --- a/Makefile +++ b/Makefile @@ -27,9 +27,11 @@ help: @echo " ODPT_ACCESS_TOKEN - Required by all bus feeds except Toei" @echo " DISABLE_BUS_FEATURE - Set to true to build rail-only data" -# worker は wasm32 用の crate なので、ネイティブのテストからは外す。 +# worker は Workers 上でしか動かないが、索引 (src/index.rs) はネイティブでも +# 動く純粋なデータ構造なので、そのユニットテストはここで走らせる。 test: cargo test -p stationapi -p stationapi-preprocessor -p data_validator + cargo test -p stationapi-worker check: cargo check -p stationapi -p stationapi-preprocessor -p data_validator diff --git a/docs/nearby-bus-stops.md b/docs/nearby-bus-stops.md index 4475cf35..6f370751 100644 --- a/docs/nearby-bus-stops.md +++ b/docs/nearby-bus-stops.md @@ -29,6 +29,8 @@ enum TransportType { | **Bus** | バス停のみを返す | | **RailAndBus** | 鉄道駅とバス停の両方を返す。`lines`配列にも近傍バス路線を含める | +**注**: `stationsNearby` の並びは常に近い順です。`transportType` を指定しない場合も鉄道とバスを分けず、距離だけで並べます。 + ## 対象API | クエリ | 近傍バス停対応 | 備考 | @@ -123,5 +125,5 @@ async fn get_nearby_bus_lines(&self, ref_lat: f64, ref_lon: f64) -> Result Vec { // ---------------------------------------------------------------- 検索 +/// 地球半径 (km)。距離計算と探索範囲の見積もりで同じ値を使う。 +const EARTH_RADIUS_KM: f64 = 6371.0; + /// 球面距離 (km)。 /// /// 度単位のユークリッド距離だと緯度と経度を同じスケールで扱うことになり、 /// 東西方向を過大評価する。ここでは実距離で並べる。 pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { - const EARTH_RADIUS_KM: f64 = 6371.0; let (p1, p2) = (lat1.to_radians(), lat2.to_radians()); let dlat = (lat2 - lat1).to_radians(); let dlon = (lon2 - lon1).to_radians(); @@ -466,67 +468,348 @@ pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { 2.0 * EARTH_RADIUS_KM * a.sqrt().clamp(-1.0, 1.0).asin() } -/// 全件走査で最近傍 limit 件を返す。11,148 駅なので索引なしで十分速い。 +/// グリッド 1 マスの一辺 (度)。約 5.5km 四方。 +/// 細かくするとマスの数 (= 索引の大きさ) が増え、粗くすると 1 マスあたりの +/// 走査件数が増える。近傍バス停の検索 (半径 300m) が 1 マスで収まる大きさ。 +const GRID_CELL_DEG: f64 = 0.05; +/// マス数の上限。`offsets` は外接矩形に比例して確保するため、外れ値の座標が +/// 1 件混ざるだけで確保量が跳ね上がる。GTFS 由来のデータは外部入力なので、 +/// 上限を超える場合はマスを粗くして収める (索引の役目は候補を絞ることなので、 +/// 粗くしても返す結果は変わらない)。 +const GRID_MAX_CELLS: usize = 1 << 22; +/// 最初に見る半径 (km)。市街地ならこの範囲で近傍バス停 50 件がそろう。 +const INITIAL_SEARCH_RADIUS_KM: f64 = 1.0; +/// 半径の内側で件数が足りなかったときに広げる倍率。 +const SEARCH_RADIUS_GROWTH: f64 = 4.0; + +/// 索引に載せられる座標か。NaN・無限大や WGS84 の範囲外は、距離計算に使えない +/// うえに外接矩形だけを広げるので載せない。 +fn indexable_coords(lat: f64, lon: f64) -> bool { + lat.is_finite() && lon.is_finite() && lat.abs() <= 90.0 && lon.abs() <= 180.0 +} + +fn cell_index(deg: f64, cell_deg: f64) -> i32 { + (deg / cell_deg).floor() as i32 +} + +/// 駅を緯度経度のマスへ割り当てた索引。 /// -/// `want` は種別の絞り込み。未指定 (RailAndBus) のときは -/// 鉄道を先・バスを後に並べたうえで距離順になる。 +/// 全件走査だと 1 回の近傍検索で駅の総数ぶん距離を計算することになる。 +/// `trainRoute` は経路上の駅ごとに近傍バス停を引くため、経路が長いほど +/// (駅数 × 駅総数) で効いていた。マスに区切っておけば探索半径の内側だけで済む。 +/// +/// 添字は `stations()` のもの。CSR 形式で、`offsets[c]..offsets[c + 1]` が +/// マス c に属する駅の `items` 上の範囲を表す。 +struct Grid { + cell_deg: f64, + min_i: i32, + min_j: i32, + rows: usize, + cols: usize, + offsets: Vec, + items: Vec, +} + +impl Grid { + fn empty() -> Self { + Grid { + cell_deg: GRID_CELL_DEG, + min_i: 0, + min_j: 0, + rows: 0, + cols: 0, + offsets: vec![0], + items: Vec::new(), + } + } + + fn is_empty(&self) -> bool { + self.items.is_empty() + } + + fn build(want: i32) -> Self { + let members: Vec = stations() + .iter() + .enumerate() + .filter(|(_, s)| s.e_status == 0 && s.transport_type as i32 == want) + .filter(|(_, s)| indexable_coords(s.lat, s.lon)) + .map(|(i, _)| i as u32) + .collect(); + if members.is_empty() { + return Grid::empty(); + } + + // 外接矩形がマス数の上限に収まるまでマスを粗くする。座標は WGS84 の + // 範囲に収まっているので、この繰り返しは必ず終わる。 + let bounds = |cell_deg: f64| -> (i32, i32, usize, usize) { + let (mut lo_i, mut hi_i) = (i32::MAX, i32::MIN); + let (mut lo_j, mut hi_j) = (i32::MAX, i32::MIN); + for &m in &members { + let s = &stations()[m as usize]; + let (i, j) = (cell_index(s.lat, cell_deg), cell_index(s.lon, cell_deg)); + lo_i = lo_i.min(i); + hi_i = hi_i.max(i); + lo_j = lo_j.min(j); + hi_j = hi_j.max(j); + } + ( + lo_i, + lo_j, + (hi_i - lo_i + 1) as usize, + (hi_j - lo_j + 1) as usize, + ) + }; + let mut cell_deg = GRID_CELL_DEG; + let (mut min_i, mut min_j, mut rows, mut cols) = bounds(cell_deg); + while rows.saturating_mul(cols) > GRID_MAX_CELLS { + cell_deg *= 2.0; + (min_i, min_j, rows, cols) = bounds(cell_deg); + } + + // 度数分布 -> 累積和 -> 配置の 3 パスで CSR を組む + let mut offsets = vec![0u32; rows * cols + 1]; + let cell_of = |s: &StationRecord| -> usize { + let i = (cell_index(s.lat, cell_deg) - min_i) as usize; + let j = (cell_index(s.lon, cell_deg) - min_j) as usize; + i * cols + j + }; + for &m in &members { + offsets[cell_of(&stations()[m as usize]) + 1] += 1; + } + for c in 0..rows * cols { + offsets[c + 1] += offsets[c]; + } + let mut cursor = offsets.clone(); + let mut items = vec![0u32; members.len()]; + for &m in &members { + let c = cell_of(&stations()[m as usize]); + items[cursor[c] as usize] = m; + cursor[c] += 1; + } + + Grid { + cell_deg, + min_i, + min_j, + rows, + cols, + offsets, + items, + } + } + + /// 半径 radius_km の円を必ず覆うマスの範囲 (両端を含む) を返す。 + /// + /// 緯度差だけの距離は `EARTH_RADIUS_KM * Δφ` なので、そこから緯度の幅を出す。 + /// 経度差だけの距離は両端の緯度が高いほど短くなるため、探索帯のうち最も + /// 極に近い緯度で見積もって幅を広めに取る。 + fn range(&self, lat: f64, lon: f64, radius_km: f64) -> (i32, i32, i32, i32) { + let dlat_deg = (radius_km / EARTH_RADIUS_KM).to_degrees(); + let cos_phi = (lat.abs() + dlat_deg).min(90.0).to_radians().cos(); + let sin_half = radius_km / (2.0 * EARTH_RADIUS_KM * cos_phi); + // cos_phi が 0 付近 (極) だと経度は絞れない。そのときは全周を見る。 + let dlon_deg = if cos_phi <= 0.0 || !sin_half.is_finite() || sin_half >= 1.0 { + 180.0 + } else { + 2.0 * sin_half.asin().to_degrees() + }; + // 日付変更線をまたぐ範囲は 2 本の区間になる。分割して扱う価値がある + // データ (日本) ではないので、その場合は経度を絞らず全周を見る。 + // 絞り込みを諦めるだけなので取りこぼしは起きない。 + let (j0, j1) = if dlon_deg >= 180.0 || lon - dlon_deg < -180.0 || lon + dlon_deg > 180.0 { + (i32::MIN, i32::MAX) + } else { + ( + cell_index(lon - dlon_deg, self.cell_deg), + cell_index(lon + dlon_deg, self.cell_deg), + ) + }; + ( + cell_index(lat - dlat_deg, self.cell_deg), + cell_index(lat + dlat_deg, self.cell_deg), + j0, + j1, + ) + } + + /// この半径で索引の全域を覆うか。覆っていればこれ以上広げても増えない。 + fn covers_all(&self, lat: f64, lon: f64, radius_km: f64) -> bool { + let (i0, i1, j0, j1) = self.range(lat, lon, radius_km); + i0 <= self.min_i + && i1 >= self.min_i + self.rows as i32 - 1 + && j0 <= self.min_j + && j1 >= self.min_j + self.cols as i32 - 1 + } + + /// 半径 radius_km の円を覆うマスに属する駅を渡す。円の外の駅も混ざる。 + fn for_each_near( + &self, + lat: f64, + lon: f64, + radius_km: f64, + mut f: impl FnMut(&'static StationRecord), + ) { + if self.is_empty() { + return; + } + let (i0, i1, j0, j1) = self.range(lat, lon, radius_km); + let i0 = i0.max(self.min_i); + let i1 = i1.min(self.min_i + self.rows as i32 - 1); + let j0 = j0.max(self.min_j); + let j1 = j1.min(self.min_j + self.cols as i32 - 1); + for i in i0..=i1 { + let row = (i - self.min_i) as usize * self.cols; + for j in j0..=j1 { + let c = row + (j - self.min_j) as usize; + for &m in &self.items[self.offsets[c] as usize..self.offsets[c + 1] as usize] { + f(&stations()[m as usize]); + } + } + } + } +} + +/// 種別ごとのグリッド。TransportType は 0 = 鉄道 / 1 = バスの 2 値。 +static GRIDS: OnceLock<[Grid; 2]> = OnceLock::new(); + +fn grid_of(want: i32) -> &'static Grid { + let grids = GRIDS.get_or_init(|| { + [ + Grid::build(TransportType::Rail as i32), + Grid::build(TransportType::Bus as i32), + ] + }); + match want { + w if w == TransportType::Bus as i32 => &grids[1], + w if w == TransportType::Rail as i32 => &grids[0], + _ => EMPTY_GRID.get_or_init(Grid::empty), + } +} + +static EMPTY_GRID: OnceLock = OnceLock::new(); + +/// 最近傍 limit 件を距離の昇順で返す。路線を引けない駅は除く。 +/// +/// `want` は種別の絞り込み。未指定 (RailAndBus) でも並びは距離順で、 +/// 鉄道とバスを区別しない。移行前の SQL は `transport_type` を第 1 キーに +/// していたため、10m 先のバス停より 5km 先の鉄道駅が先に来ていた。 +/// `stationsNearby` は近い順が仕様なので、種別で先に分けない。 pub fn nearest( lat: f64, lon: f64, limit: usize, want: Option, ) -> Vec<(&'static StationRecord, f64)> { - nearest_inner(lat, lon, limit, want, true) + // 索引に載せられない座標では探索を打ち切れない。lat が NaN だと + // covers_all が永久に false のままで、半径を無限大まで広げ続けても + // 抜けられない (リクエストが返らなくなる)。入口で弾く。 + if !indexable_coords(lat, lon) { + return Vec::new(); + } + let Some(want) = want else { + // 全体の上位 limit 件は種別ごとの上位 limit 件の和集合に必ず含まれる + // (ある駅より近い駅が limit 件未満なら、同じ種別の中でも limit 件未満)。 + // 種別ごとに引いてから距離で混ぜ直す。 + let mut out = nearest_of_type(lat, lon, limit, TransportType::Rail as i32); + out.extend(nearest_of_type(lat, lon, limit, TransportType::Bus as i32)); + if limit < out.len() { + out.select_nth_unstable_by(limit, by_distance_then_station_cd); + out.truncate(limit); + } + out.sort_unstable_by(by_distance_then_station_cd); + return out; + }; + nearest_of_type(lat, lon, limit, want) +} + +/// 距離の昇順、同着なら station_cd の昇順。 +/// +/// 同じ駅グループの駅は路線ごとに行が分かれるうえ座標を共有するため、距離だけで +/// 並べると同着が多数出る。以前は不安定ソートに任せていたので、どの路線の行が +/// 先に来るかがビルドごとに変わり得た。並びを決め切っておく。 +fn by_distance_then_station_cd( + a: &(&'static StationRecord, f64), + b: &(&'static StationRecord, f64), +) -> std::cmp::Ordering { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.station_cd.cmp(&b.0.station_cd)) } -/// 路線の存在を条件にしないまま最近傍を取る。 +/// 半径 radius_km 以内の駅を距離の昇順で返す。件数の上限は掛けない。 /// -/// 近傍バス停の検索は先に件数を絞ってから路線の有無を見る。 -/// 先に路線で絞ると件数が変わるため、この順序を保つ用途で使う。 -pub fn nearest_without_line_join( +/// 「上位 N 件」ではなく半径で切る用途 (駅の近傍バス停) 向け。最寄り N 件を +/// 作ってから半径で捨てると、採用されない駅の分まで組み立てることになる。 +/// `nearest` と違い、路線を引けるかどうかは見ない (呼び出し側が +/// 路線で絞ってから件数を確定させるため)。 +pub fn within_radius( lat: f64, lon: f64, - limit: usize, - want: Option, + radius_km: f64, + want: i32, ) -> Vec<(&'static StationRecord, f64)> { - nearest_inner(lat, lon, limit, want, false) + let mut out: Vec<(&'static StationRecord, f64)> = Vec::new(); + // `nearest` と同じ理由で、索引に載せられない座標は入口で弾く + if !radius_km.is_finite() || radius_km < 0.0 || !indexable_coords(lat, lon) { + return out; + } + grid_of(want).for_each_near(lat, lon, radius_km, |record| { + let distance = haversine_km(lat, lon, record.lat, record.lon); + if distance <= radius_km { + out.push((record, distance)); + } + }); + out.sort_unstable_by(by_distance_then_station_cd); + out } -fn nearest_inner( +/// 指定した種別の駅から最近傍 limit 件を距離昇順で返す。 +/// +/// 半径 r の範囲に limit 件そろえば、r より外に上位 limit 件は存在しない。 +/// そこでグリッド索引で半径 r の内側だけを見て、足りなければ r を広げる。 +/// 索引の外接矩形を覆っても足りなければ、その種別の全件がそろっている。 +fn nearest_of_type( lat: f64, lon: f64, limit: usize, - want: Option, - require_line: bool, + want: i32, ) -> Vec<(&'static StationRecord, f64)> { - let mut scored: Vec<(&StationRecord, f64)> = stations() - .iter() - .filter(|s| s.e_status == 0) - .filter(|s| !require_line || joins_line(s)) - .filter(|s| want.is_none_or(|w| s.transport_type as i32 == w)) - .map(|s| (s, haversine_km(lat, lon, s.lat, s.lon))) - .collect(); + if limit == 0 { + return Vec::new(); + } + let grid = grid_of(want); + if grid.is_empty() { + return Vec::new(); + } - // 種別指定がある場合は第1キーが定数 0 になるので距離だけで並ぶ - let rank = move |s: &StationRecord| -> i32 { - if want.is_none() { - s.transport_type as i32 - } else { - 0 + let mut radius_km = INITIAL_SEARCH_RADIUS_KM; + loop { + let covers_all = grid.covers_all(lat, lon, radius_km); + let mut scored: Vec<(&'static StationRecord, f64)> = Vec::new(); + let mut within = 0usize; + grid.for_each_near(lat, lon, radius_km, |record| { + if !joins_line(record) { + return; + } + let distance = haversine_km(lat, lon, record.lat, record.lon); + if distance <= radius_km { + within += 1; + } + scored.push((record, distance)); + }); + + // 半径の内側で limit 件そろっていれば、外側を見る必要はない。 + // 覆い切った場合はそれ以上広げても増えないので打ち切る。 + if within >= limit || covers_all { + if limit < scored.len() { + scored.select_nth_unstable_by(limit, by_distance_then_station_cd); + scored.truncate(limit); + } + scored.sort_unstable_by(by_distance_then_station_cd); + return scored; } - }; - let cmp = move |a: &(&StationRecord, f64), b: &(&StationRecord, f64)| { - rank(a.0) - .cmp(&rank(b.0)) - .then_with(|| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) - }; - // 全体ソートを避け、上位 limit 件だけを確定させる - if limit < scored.len() { - scored.select_nth_unstable_by(limit, cmp); - scored.truncate(limit); + radius_km *= SEARCH_RADIUS_GROWTH; } - scored.sort_unstable_by(cmp); - scored } /// 駅名・読み・ローマ字・中国語・韓国語のいずれかへの部分一致で引く。 @@ -907,3 +1190,259 @@ pub fn apply_line_alias(line: &mut Line, station_cd: i32) { line.line_name_ko = pick(alias.line_name_ko.as_ref(), line.line_name_ko.clone()); line.line_color_c = pick(alias.line_color_c.as_ref(), line.line_color_c.clone()); } + +#[cfg(test)] +mod tests { + use super::*; + + /// グリッド索引の正解となる全件走査。索引を入れる前の実装そのもの。 + fn nearest_by_full_scan( + lat: f64, + lon: f64, + limit: usize, + want: Option, + ) -> Vec<(&'static StationRecord, f64)> { + let mut scored: Vec<(&StationRecord, f64)> = stations() + .iter() + .filter(|s| s.e_status == 0) + .filter(|s| joins_line(s)) + .filter(|s| want.is_none_or(|w| s.transport_type as i32 == w)) + .map(|s| (s, haversine_km(lat, lon, s.lat, s.lon))) + .collect(); + + // 種別を指定してもしなくても並びは距離順 (`stationsNearby` の仕様) + if limit < scored.len() { + scored.select_nth_unstable_by(limit, by_distance_then_station_cd); + scored.truncate(limit); + } + scored.sort_unstable_by(by_distance_then_station_cd); + scored + } + + fn assert_same_as_full_scan(lat: f64, lon: f64, limit: usize, want: Option) { + let expected = nearest_by_full_scan(lat, lon, limit, want); + let actual = nearest(lat, lon, limit, want); + assert_eq!( + expected.len(), + actual.len(), + "件数が違う ({lat}, {lon}) want={want:?} limit={limit}" + ); + for (i, (e, a)) in expected.iter().zip(actual.iter()).enumerate() { + // 距離が同着の駅は全件走査側の並びが決まらないので距離だけを見る + assert!( + (e.1 - a.1).abs() < 1e-9, + "{i} 件目が違う ({lat}, {lon}) want={want:?} limit={limit}: \ + 期待 {} 実際 {}", + e.1, + a.1 + ); + } + } + + /// グリッド索引は全件走査と同じ結果を返す。 + /// 索引の絞り込みが範囲を取りこぼすと最近傍が欠けるため、実データで突き合わせる。 + #[test] + fn grid_search_matches_full_scan() { + // 実在の駅の座標を、データの大きさによらず 40 点ほど抜き出す + let step = (stations().len() / 40).max(1); + let sampled = stations().iter().step_by(step).map(|s| (s.lat, s.lon)); + // 駅から離れた座標 (海上・国外)、および索引の外側 + let outside = [ + (35.0, 145.0), + (43.5, 141.0), + (26.2, 127.7), + (0.0, 0.0), + (51.5, -0.1), + (-33.9, 151.2), + ]; + for (lat, lon) in sampled.chain(outside) { + for want in [None, Some(0), Some(1)] { + for limit in [1usize, 50] { + assert_same_as_full_scan(lat, lon, limit, want); + } + } + } + } + + /// 極や日付変更線の付近でも打ち切れること。 + /// 経度の絞り込みが日付変更線をまたぐ場合、範囲が索引を覆えず + /// 半径を広げ続ける (無限ループになる) 経路があった。 + #[test] + fn grid_search_terminates_at_the_poles_and_the_antimeridian() { + for (lat, lon) in [ + (89.9, 179.9), + (-89.9, -179.9), + (89.9, -179.9), + (-89.9, 179.9), + (35.0, 179.99), + (35.0, -179.99), + (90.0, 0.0), + (-90.0, 0.0), + ] { + for want in [None, Some(0), Some(1)] { + assert_same_as_full_scan(lat, lon, 5, want); + } + } + } + + /// 半径 0 件要求と、存在しない種別を渡した場合。 + #[test] + fn grid_search_handles_degenerate_requests() { + assert!(nearest(35.681382, 139.766084, 0, None).is_empty()); + assert!(nearest(35.681382, 139.766084, 5, Some(99)).is_empty()); + } + + /// `within_radius` は半径以内の駅を距離の昇順で漏れなく返す。 + /// 近傍バス停の採否をそのまま決めるので、全件走査と突き合わせる。 + #[test] + fn within_radius_matches_full_scan() { + let rail = TransportType::Rail as i32; + let step = (stations().len() / 30).max(1); + for record in stations().iter().step_by(step) { + for radius_km in [0.0, 0.3, 2.0, 25.0] { + let mut expected: Vec<(i32, f64)> = stations() + .iter() + .filter(|s| s.e_status == 0 && s.transport_type as i32 == rail) + .map(|s| { + ( + s.station_cd, + haversine_km(record.lat, record.lon, s.lat, s.lon), + ) + }) + .filter(|(_, d)| *d <= radius_km) + .collect(); + expected.sort_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); + let actual: Vec<(i32, f64)> = + within_radius(record.lat, record.lon, radius_km, rail) + .into_iter() + .map(|(s, d)| (s.station_cd, d)) + .collect(); + assert_eq!( + expected, actual, + "({}, {}) radius={radius_km}km", + record.lat, record.lon + ); + } + } + } + + /// 半径ちょうどの駅を含み、負の半径は空を返す。 + #[test] + fn within_radius_handles_the_boundary_and_negative_radius() { + let rail = TransportType::Rail as i32; + let origin = &stations()[0]; + // 自分自身は距離 0 なので、半径 0 でも含まれる + let at_zero = within_radius(origin.lat, origin.lon, 0.0, rail); + assert!( + at_zero + .iter() + .any(|(s, _)| s.station_cd == origin.station_cd), + "半径 0 で距離 0 の駅が落ちている" + ); + assert!(at_zero.iter().all(|(_, d)| *d == 0.0)); + + // 2 番目に近い駅の距離を半径にすると、その駅は含まれる (境界を含む) + let near = within_radius(origin.lat, origin.lon, 50.0, rail); + if let Some((boundary, distance)) = near.last().map(|(s, d)| (s.station_cd, *d)) { + let exact = within_radius(origin.lat, origin.lon, distance, rail); + assert!( + exact.iter().any(|(s, _)| s.station_cd == boundary), + "半径ちょうどの駅が落ちている" + ); + } + + assert!(within_radius(origin.lat, origin.lon, -1.0, rail).is_empty()); + } + + /// 索引に載せられない座標を渡しても打ち切れること。 + /// + /// lat が NaN だと covers_all が永久に false のままで、半径を無限大まで + /// 広げ続けても抜けられない。入口で弾いていないとこのテストは終わらない。 + #[test] + fn nearest_rejects_coordinates_it_cannot_index() { + for (lat, lon) in [ + (f64::NAN, 139.766084), + (35.681382, f64::NAN), + (f64::INFINITY, 139.766084), + (35.681382, f64::NEG_INFINITY), + (90.1, 139.766084), + (35.681382, 180.1), + ] { + for want in [None, Some(TransportType::Rail as i32)] { + assert!( + nearest(lat, lon, 5, want).is_empty(), + "({lat}, {lon}) want={want:?} が空でない" + ); + } + assert!(within_radius(lat, lon, 1.0, TransportType::Rail as i32).is_empty()); + } + } + + /// 索引に載せられない座標を弾く。NaN や範囲外が混ざると外接矩形だけが + /// 広がり、マスの確保量が跳ね上がる。 + #[test] + fn indexable_coords_rejects_invalid_values() { + assert!(indexable_coords(35.681382, 139.766084)); + assert!(indexable_coords(-90.0, 180.0)); + assert!(!indexable_coords(f64::NAN, 139.0)); + assert!(!indexable_coords(35.0, f64::INFINITY)); + assert!(!indexable_coords(90.1, 139.0)); + assert!(!indexable_coords(35.0, 180.1)); + } + + /// 実データのグリッドがマス数の上限に収まっている。 + #[test] + fn grid_stays_within_the_cell_cap() { + for want in [TransportType::Rail as i32, TransportType::Bus as i32] { + let grid = grid_of(want); + assert!( + grid.rows * grid.cols <= GRID_MAX_CELLS, + "種別 {want} のマス数 {} が上限を超えている", + grid.rows * grid.cols + ); + } + } + + /// 索引が返す距離は haversine_km と一致し、距離の昇順に並ぶ。 + #[test] + fn grid_search_returns_sorted_distances() { + let hits = nearest(35.681382, 139.766084, 20, Some(TransportType::Rail as i32)); + assert!(!hits.is_empty()); + for pair in hits.windows(2) { + assert!(pair[0].1 <= pair[1].1, "距離の昇順になっていない"); + } + for (record, distance) in &hits { + let expected = haversine_km(35.681382, 139.766084, record.lat, record.lon); + assert!((expected - distance).abs() < 1e-9); + } + } + + /// `stationsNearby` は近い順が仕様。種別を指定しない場合も、鉄道とバスを + /// 分けずに距離だけで並べる。移行前の SQL は `transport_type` を第 1 キーに + /// していたため、近いバス停より遠い鉄道駅が先に来ていた。 + #[test] + fn nearest_orders_by_distance_regardless_of_transport_type() { + let step = (stations().len() / 40).max(1); + for record in stations().iter().step_by(step) { + for limit in [5usize, 50] { + let hits = nearest(record.lat, record.lon, limit, None); + for pair in hits.windows(2) { + assert!( + pair[0].1 <= pair[1].1, + "({}, {}) limit={limit}: 距離の昇順になっていない ({} -> {}, 種別 {:?} -> {:?})", + record.lat, + record.lon, + pair[0].1, + pair[1].1, + pair[0].0.transport_type, + pair[1].0.transport_type + ); + } + } + } + } +} diff --git a/src/repository.rs b/src/repository.rs index 44037503..e9f21555 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -322,31 +322,33 @@ impl StationRepository for MemStationRepository { .collect()) } - /// 各座標につきバス停の最寄り N 件を取り、そのあと有効な路線を持つものだけに絞る。 - /// 先に路線で絞ると件数が変わるため、この順序を保つ。 + /// 各座標につき半径以内のバス停を近い順に見て、有効な路線を持つものだけを + /// N 件まで採る。上限を先に掛けると、路線を引けないバス停や廃止路線の + /// バス停が枠を埋めたぶんだけ件数が減るため、絞り込みを先に行う。 /// 並びは指定された座標の順、その中では距離順。 async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + radius_meters: f64, ) -> Result, DomainError> { - let want = Some(TransportType::Bus as i32); + let want = TransportType::Bus as i32; let limit = limit_per_station as usize; + let radius_km = radius_meters / 1000.0; let mut out = Vec::new(); for &(source_g_cd, lat, lon) in coords { - for (record, _distance) in index::nearest_without_line_join(lat, lon, limit, want) { - let Some(line) = index::line_by_cd(record.line_cd) else { - continue; - }; - if line.e_status != 0 { - continue; - } - let mut station = record.to_entity(Some(line)); - station.line_group_cd = index::first_line_group_cd(record.station_cd); - station.has_train_types = station.line_group_cd.is_some(); - out.push((source_g_cd, station)); - } + let hits = index::within_radius(lat, lon, radius_km, want) + .into_iter() + .filter_map(|(record, _distance)| { + let line = index::line_by_cd(record.line_cd).filter(|l| l.e_status == 0)?; + let mut station = record.to_entity(Some(line)); + station.line_group_cd = index::first_line_group_cd(record.station_cd); + station.has_train_types = station.line_group_cd.is_some(); + Some((source_g_cd, station)) + }) + .take(limit); + out.extend(hits); } Ok(out) } @@ -930,10 +932,14 @@ pub struct MemCompanyRepository; #[async_trait] impl CompanyRepository for MemCompanyRepository { + /// 事業者は 179 件と少ないが、駅ごとの付帯情報を組み立てるたびに呼ばれる。 + /// `id_vec.contains` のままだと 1 回の呼び出しで (事業者数 × 要求 ID 数) の + /// 比較になるため、集合に入れてから引く。 async fn find_by_id_vec(&self, id_vec: &[u32]) -> Result, DomainError> { + let wanted: HashSet = id_vec.iter().copied().collect(); Ok(index::companies() .iter() - .filter(|c| id_vec.contains(&(c.company_cd as u32))) + .filter(|c| wanted.contains(&(c.company_cd as u32))) .cloned() .collect()) } diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index 41589eee..fc1a69b3 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -82,10 +82,19 @@ pub trait StationRepository: Send + Sync + 'static { }) .collect()) } + /// 各座標から `radius_meters` 以内のバス停を、近い順に最大 + /// `limit_per_station` 件返す。半径の外は呼び出し側でも採用されないため、 + /// ここで切っておく (全国の最寄り N 件を作ってから捨てると、駅数に比例して + /// 無駄が積み上がる)。 + /// + /// 半径が有限でない (`NaN` / 無限大) 場合と負の場合は空を返す。無限大を + /// 距離の比較にそのまま使うと全件が半径内と判定されるため、実装ごとに + /// 結果が食い違わないようここで決めておく。 async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], // (station_g_cd, lat, lon) limit_per_station: u32, + radius_meters: f64, ) -> Result, DomainError>; async fn get_route_stops( &self, @@ -261,19 +270,63 @@ mod tests { Ok(result) } + /// trait の契約どおり、半径で絞ってから件数を切る。 + /// + /// `get_by_coordinates` が `distance` に入れるのは緯度経度の度で測った + /// ユークリッド距離なので、メートルの半径とは比較できない。ここでは + /// 距離を測り直す。件数を先に切ると、半径の外の駅が枠を埋めた分だけ + /// 返る件数が本来より少なくなる。 async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + radius_meters: f64, ) -> Result, DomainError> { + // 無限大をそのまま比較に使うと全件が半径内になる + if !radius_meters.is_finite() || radius_meters < 0.0 { + return Ok(Vec::new()); + } + + /// 球面距離 (m)。 + fn haversine_meters(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const EARTH_RADIUS_M: f64 = 6_371_000.0; + let (p1, p2) = (lat1.to_radians(), lat2.to_radians()); + let dlat = (lat2 - lat1).to_radians(); + let dlon = (lon2 - lon1).to_radians(); + let a = + (dlat / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dlon / 2.0).sin().powi(2); + 2.0 * EARTH_RADIUS_M * a.sqrt().clamp(-1.0, 1.0).asin() + } + let mut result = Vec::new(); for &(source_g_cd, lat, lon) in coords { let stops = self - .get_by_coordinates(lat, lon, Some(limit_per_station), Some(TransportType::Bus)) + .get_by_coordinates(lat, lon, None, Some(TransportType::Bus)) .await?; - for stop in stops { - result.push((source_g_cd, stop)); - } + // get_by_coordinates の並びは度で測ったユークリッド距離順で、 + // 緯度の高い地点では球面距離順と一致しない。件数を切る前に + // 測り直した距離で並べ直す。 + let mut within: Vec = stops + .into_iter() + .filter_map(|mut stop| { + let meters = haversine_meters(lat, lon, stop.lat, stop.lon); + (meters <= radius_meters).then(|| { + stop.distance = Some(meters); + stop + }) + }) + .collect(); + // 元の並びは HashMap の反復順なので、同距離の順序を距離だけに + // 任せると件数を切ったときにどのバス停が残るか実行ごとに変わる。 + // 索引側 (by_distance_then_station_cd) と同じく station_cd で決める。 + within.sort_by(|a, b| { + a.distance + .partial_cmp(&b.distance) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.station_cd.cmp(&b.station_cd)) + }); + within.truncate(limit_per_station as usize); + result.extend(within.into_iter().map(|stop| (source_g_cd, stop))); } Ok(result) } @@ -455,6 +508,137 @@ mod tests { ) } + /// 指定した座標にバス停を置いたモック。半径の扱いを検証するために使う。 + fn bus_stop_repository(stops: &[(i32, f64, f64)]) -> MockStationRepository { + let mut stations = HashMap::new(); + for &(station_cd, lat, lon) in stops { + let mut stop = + create_test_station(station_cd, &format!("バス停{station_cd}"), 500, lat, lon); + stop.transport_type = TransportType::Bus; + stations.insert(station_cd as u32, stop); + } + MockStationRepository { stations } + } + + /// 東京駅から北へおよそ meters メートルの緯度。 + fn lat_north_of_tokyo(meters: f64) -> f64 { + 35.681236 + meters / 111_195.0 + } + + #[tokio::test] + async fn test_get_bus_stops_near_stations_excludes_stops_outside_the_radius() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(100.0), 139.767125), + (902, lat_north_of_tokyo(250.0), 139.767125), + (903, lat_north_of_tokyo(500.0), 139.767125), + ]); + + let result = repo + .get_bus_stops_near_stations(&[(1, 35.681236, 139.767125)], 50, 300.0) + .await + .unwrap(); + + // 300m を超える 903 は含まれず、近い順に並ぶ + let ids: Vec = result.iter().map(|(_, s)| s.station_cd).collect(); + assert_eq!(ids, vec![901, 902]); + // 距離はメートルで入る + let distances: Vec = result.iter().map(|(_, s)| s.distance.unwrap()).collect(); + assert!((distances[0] - 100.0).abs() < 5.0, "{distances:?}"); + assert!((distances[1] - 250.0).abs() < 5.0, "{distances:?}"); + // 呼び出し元の座標に紐づく + assert!(result.iter().all(|(source_g_cd, _)| *source_g_cd == 1)); + } + + /// 件数の上限は半径で絞ったあとに掛ける。先に切ると、半径の外の駅が枠を + /// 埋めた分だけ返る件数が本来より少なくなる。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_applies_the_limit_after_the_radius() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(1000.0), 139.767125), + (902, lat_north_of_tokyo(2000.0), 139.767125), + (903, lat_north_of_tokyo(100.0), 139.767125), + (904, lat_north_of_tokyo(200.0), 139.767125), + ]); + + let result = repo + .get_bus_stops_near_stations(&[(1, 35.681236, 139.767125)], 2, 300.0) + .await + .unwrap(); + + // 半径の外にある 901 / 902 が枠を消費しない + let ids: Vec = result.iter().map(|(_, s)| s.station_cd).collect(); + assert_eq!(ids, vec![903, 904]); + } + + /// 同距離の並びは station_cd の昇順。元の並びは HashMap の反復順なので、 + /// 決め切っていないと件数を切ったときの結果が実行ごとに変わる。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_breaks_ties_by_station_cd() { + let lat = lat_north_of_tokyo(100.0); + let repo = bus_stop_repository(&[(903, lat, 139.767125), (901, lat, 139.767125)]); + + let result = repo + .get_bus_stops_near_stations(&[(1, 35.681236, 139.767125)], 1, 300.0) + .await + .unwrap(); + + let ids: Vec = result.iter().map(|(_, s)| s.station_cd).collect(); + assert_eq!(ids, vec![901]); + } + + /// 座標ごとにまとまり、その中では距離順。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_groups_by_source_coordinate() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(100.0), 139.767125), + (902, lat_north_of_tokyo(200.0), 139.767125), + ]); + + let result = repo + .get_bus_stops_near_stations( + &[ + (1, 35.681236, 139.767125), + (2, lat_north_of_tokyo(200.0), 139.767125), + ], + 50, + 300.0, + ) + .await + .unwrap(); + + let pairs: Vec<(u32, i32)> = result + .iter() + .map(|(source_g_cd, s)| (*source_g_cd, s.station_cd)) + .collect(); + assert_eq!(pairs, vec![(1, 901), (1, 902), (2, 902), (2, 901)]); + } + + /// 半径が有限でない場合と負の場合は空を返す。無限大をそのまま比較に使うと + /// 全件が半径内と判定され、本番実装 (index::within_radius) と食い違う。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_rejects_an_invalid_radius() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(100.0), 139.767125), + (902, lat_north_of_tokyo(5000.0), 139.767125), + ]); + let coords = [(1u32, 35.681236, 139.767125)]; + + for radius in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + let result = repo + .get_bus_stops_near_stations(&coords, 50, radius) + .await + .unwrap(); + assert!(result.is_empty(), "半径 {radius} で空にならない"); + } + + // 有限の半径では従来どおり返る + let result = repo + .get_bus_stops_near_stations(&coords, 50, 300.0) + .await + .unwrap(); + assert_eq!(result.len(), 1); + } + #[tokio::test] async fn test_find_by_id_existing() { let repo = MockStationRepository::new(); diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index d8968fe6..91e1b6cb 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -950,8 +950,14 @@ where entity_type: "line group", entity_id: "unspecified".to_string(), })?; + // 系統の停車駅は付帯情報を付ける前に取り、要求された区間へ切り詰めてから + // 付帯情報を付ける。付帯情報の付与 (所属路線・事業者・種別・近傍バス路線) + // は駅ごとに独立しているため、切り詰めてから付けても各駅の内容は変わらない。 + // 先に系統全体へ付けると、3 駅だけを要求されても 250 駅ぶんを組み立てる + // ことになり、区間の長さに関係なく同じ費用が掛かっていた。 let stations = self - .get_stations_by_line_group_id(line_group_id, TransportTypeFilter::RailAndBus) + .station_repository + .get_by_line_group_id(line_group_id) .await?; let from_idx = stations @@ -976,6 +982,14 @@ where v.reverse(); v }; + let sliced = self + .update_station_vec_with_attributes( + sliced, + Some(line_group_id), + TransportTypeFilter::RailAndBus, + false, + ) + .await?; let mut segments: Vec = Vec::with_capacity(sliced.len()); // 経路スライス内で路線ごとに通過駅があるか。通過駅が無い路線では優等種別でも @@ -1463,10 +1477,11 @@ where &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + radius_meters: f64, ) -> Result, UseCaseError> { let result = self .station_repository - .get_bus_stops_near_stations(coords, limit_per_station) + .get_bus_stops_near_stations(coords, limit_per_station, radius_meters) .await?; Ok(result) @@ -1519,6 +1534,29 @@ where vec![] }; + // 候補は駅グループごとに 1 つの代表座標で引くが、採否は駅ごとの座標で + // 決まる。代表座標と各駅の座標の隔たりぶんを半径に足しておかないと、 + // 代表からは半径の外だが同じグループの別の駅からは内側、というバス停を + // 取りこぼす。 + let bus_search_radius_meters = if should_include_bus_routes { + let anchors: HashMap = unique_bus_coords + .iter() + .map(|&(group_id, lat, lon)| (group_id as i32, (lat, lon))) + .collect(); + let max_offset = stations + .iter() + .filter(|s| s.transport_type == TransportType::Rail) + .filter_map(|s| { + anchors + .get(&s.station_g_cd) + .map(|&(lat, lon)| haversine_distance(lat, lon, s.lat, s.lon)) + }) + .fold(0.0_f64, f64::max); + NEARBY_BUS_STOP_RADIUS_METERS + max_offset + } else { + 0.0 + }; + // Phase 1: independent lookups in parallel. // When skip_types_join is true, skip the expensive train-type lookups // (used by the lineListStations query). @@ -1528,14 +1566,22 @@ where // Group stations already fetched by expanded primary query let (lines, bus) = tokio::try_join!( self.get_lines_by_station_group_id_vec_no_types(&station_group_ids), - self.get_bus_stops_near_stations(&unique_bus_coords, 50), + self.get_bus_stops_near_stations( + &unique_bus_coords, + 50, + bus_search_radius_meters + ), )?; (prefetched, lines, bus) } else { tokio::try_join!( self.get_stations_by_group_id_vec_no_types(&station_group_ids), self.get_lines_by_station_group_id_vec_no_types(&station_group_ids), - self.get_bus_stops_near_stations(&unique_bus_coords, 50), + self.get_bus_stops_near_stations( + &unique_bus_coords, + 50, + bus_search_radius_meters + ), )? } } else { @@ -1544,7 +1590,7 @@ where self.get_lines_by_station_group_id_vec(&station_group_ids), )?; let bus = self - .get_bus_stops_near_stations(&unique_bus_coords, 50) + .get_bus_stops_near_stations(&unique_bus_coords, 50, bus_search_radius_meters) .await?; (s, l, bus) }; @@ -1559,10 +1605,25 @@ where .push(station); } - // Collect all bus station group IDs for batch bus lines fetch - let mut all_bus_station_group_ids: Vec = bus_candidate_cache - .values() - .flat_map(|stops| stops.iter().map(|s| s.station_g_cd as u32)) + // Collect all bus station group IDs for batch bus lines fetch. + // 候補は駅グループの代表座標で引いた最寄り N 件なので、実際に採用される + // のは各駅の座標から NEARBY_BUS_STOP_RADIUS_METERS 以内のものだけ。 + // ここで先に絞らないと、採用されないバス停の駅グループぶんまで路線を + // 引くことになり、経路が長いほど無駄が (駅数 × N) で効く。 + // 採否の判定は下の駅ごとのループと同じ式を使う。 + let mut all_bus_station_group_ids: Vec = stations + .iter() + .filter(|s| s.transport_type == TransportType::Rail) + .filter_map(|s| bus_candidate_cache.get(&s.station_g_cd).map(|c| (s, c))) + .flat_map(|(station, candidates)| { + candidates + .iter() + .filter(move |bus_stop| { + haversine_distance(station.lat, station.lon, bus_stop.lat, bus_stop.lon) + <= NEARBY_BUS_STOP_RADIUS_METERS + }) + .map(|bus_stop| bus_stop.station_g_cd as u32) + }) .collect::>() .into_iter() .collect(); @@ -2312,6 +2373,7 @@ mod tests { &self, _: &[(u32, f64, f64)], _: u32, + _: f64, ) -> Result, DomainError> { Ok(vec![]) } @@ -3163,6 +3225,7 @@ mod tests { &self, _: &[(u32, f64, f64)], _: u32, + _: f64, ) -> Result, DomainError> { Ok(vec![]) } @@ -3632,6 +3695,7 @@ mod tests { &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + _: f64, ) -> Result, DomainError> { let mut result = Vec::new(); for &(source_g_cd, lat, lon) in coords { @@ -5090,6 +5154,7 @@ mod tests { &self, _: &[(u32, f64, f64)], _: u32, + _: f64, ) -> Result, DomainError> { Ok(vec![]) } @@ -5387,4 +5452,459 @@ mod tests { assert!(routes.is_empty()); } } + + /// `get_train_route` は要求された区間ぶんだけ付帯情報を組み立てる。 + /// 系統全体へ付けてから切り出していた頃は、3 駅を要求しても系統の全駅を + /// 組み立てていた。区間の長さに費用が比例することをここで固定する。 + mod get_train_route_tests { + use super::*; + use crate::domain::{ + entity::company::Company, + error::DomainError, + repository::{ + company_repository::CompanyRepository, line_repository::LineRepository, + station_repository::StationRepository, train_type_repository::TrainTypeRepository, + }, + }; + use std::sync::{Arc, Mutex}; + + /// 呼び出し内容の記録。テスト側と repository で共有する。 + #[derive(Clone, Default)] + struct Calls { + enriched_group_ids: Arc>>>, + bus_coord_counts: Arc>>, + bus_radii: Arc>>, + } + + /// 系統の停車駅を返し、付帯情報の付与で要求された駅グループ ID を記録する + struct RecordingStationRepository { + line_group_stations: Vec, + calls: Calls, + } + + #[async_trait::async_trait] + impl StationRepository for RecordingStationRepository { + async fn get_by_line_group_id(&self, _: u32) -> Result, DomainError> { + Ok(self.line_group_stations.clone()) + } + async fn get_by_station_group_id_vec( + &self, + ids: &[u32], + ) -> Result, DomainError> { + self.calls + .enriched_group_ids + .lock() + .unwrap() + .push(ids.to_vec()); + Ok(self + .line_group_stations + .iter() + .filter(|s| ids.contains(&(s.station_g_cd as u32))) + .cloned() + .collect()) + } + async fn get_bus_stops_near_stations( + &self, + coords: &[(u32, f64, f64)], + _: u32, + radius_meters: f64, + ) -> Result, DomainError> { + self.calls + .bus_coord_counts + .lock() + .unwrap() + .push(coords.len()); + self.calls.bus_radii.lock().unwrap().push(radius_meters); + Ok(vec![]) + } + async fn find_by_id(&self, _: u32) -> Result, DomainError> { + Ok(None) + } + async fn get_by_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_id( + &self, + _: u32, + _: Option, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_id_vec_with_group_stations( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id_vec_no_types( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_coordinates( + &self, + _: f64, + _: f64, + _: Option, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_name( + &self, + _: String, + _: Option, + _: Option, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_route_stops( + &self, + _: u32, + _: u32, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_route_stops_by_station_cd( + &self, + _: u32, + _: u32, + _: &[u32], + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + } + + struct StubLineRepository; + + #[async_trait::async_trait] + impl LineRepository for StubLineRepository { + async fn find_by_id(&self, _: u32) -> Result, DomainError> { + Ok(None) + } + async fn find_by_station_id(&self, _: u32) -> Result, DomainError> { + Ok(None) + } + async fn get_by_ids(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id_vec( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id_vec_no_types( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec_for_routes( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_name( + &self, + _: String, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + } + + /// 区間内の駅に種別を付ける。速度プロファイルを各停から引き上げるのは + /// LimitedExpress (4) と HighSpeedRapid (5) だけなので、特急を返す。 + struct StubTrainTypeRepository; + + #[async_trait::async_trait] + impl TrainTypeRepository for StubTrainTypeRepository { + async fn get_types_by_station_id_vec( + &self, + station_id_vec: &[u32], + _: Option, + ) -> Result, DomainError> { + Ok(station_id_vec + .iter() + .map(|&cd| TrainType { + id: Some(cd as i32), + station_cd: Some(cd as i32), + type_cd: Some(1), + line_group_cd: Some(1000), + pass: None, + type_name: "特急".to_string(), + type_name_k: "トッキュウ".to_string(), + type_name_r: None, + type_name_zh: None, + type_name_ko: None, + color: "#FF0000".to_string(), + direction: None, + kind: Some(4), + line: None, + lines: vec![], + }) + .collect()) + } + async fn get_by_line_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_id_vec( + &self, + _: &[u32], + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_line_group_ids_by_station_group_ids( + &self, + _: &[u32], + ) -> Result>, DomainError> { + Ok(std::collections::HashMap::new()) + } + async fn find_by_line_group_id_and_line_id( + &self, + _: u32, + _: u32, + ) -> Result, DomainError> { + Ok(None) + } + async fn find_by_line_group_id_and_line_id_vec( + &self, + _: &[(u32, u32)], + ) -> Result, DomainError> { + Ok(std::collections::HashMap::new()) + } + } + + struct StubCompanyRepository; + + #[async_trait::async_trait] + impl CompanyRepository for StubCompanyRepository { + async fn find_by_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + } + + type TestInteractor = QueryInteractor< + RecordingStationRepository, + StubLineRepository, + StubTrainTypeRepository, + StubCompanyRepository, + >; + + /// 20 駅の系統を作る。うち 1 駅おきに通過駅を混ぜる。 + fn build_line_group(len: i32) -> Vec { + (0..len) + .map(|i| { + let cd = 1000 + i; + let mut station = create_test_station(cd, 2000 + i, 10, Some(1000)); + // 在来線 (LineType::Normal)。新幹線だと路線側の上限が種別の + // 下限を上回るため、種別の有無で速度が変わらない + station.line_type = Some(2); + // 東京駅から北へ 1km 刻みに並べる + station.lat = 35.6812 + f64::from(i) * 0.009; + station.lon = 139.7671; + if i % 2 == 1 { + station.stop_condition = StopCondition::Not; + station.pass = Some(1); + } + station + }) + .collect() + } + + fn build_interactor(stations: Vec) -> (TestInteractor, Calls) { + let calls = Calls::default(); + let interactor = QueryInteractor { + station_repository: RecordingStationRepository { + line_group_stations: stations, + calls: calls.clone(), + }, + line_repository: StubLineRepository, + train_type_repository: StubTrainTypeRepository, + company_repository: StubCompanyRepository, + }; + (interactor, calls) + } + + #[tokio::test] + async fn enriches_only_the_requested_range() { + let (interactor, calls) = build_interactor(build_line_group(20)); + + let segments = interactor + .get_train_route(1002, 1004, Some(1000)) + .await + .unwrap(); + + assert_eq!(segments.len(), 3); + let enriched = calls.enriched_group_ids.lock().unwrap(); + assert_eq!(enriched.len(), 1); + // 系統は 20 駅だが、付帯情報を求めたのは要求された 3 駅ぶんだけ + assert_eq!(enriched[0], vec![2002, 2003, 2004]); + // 近傍バス停の検索も同じ 3 駅ぶん + assert_eq!(*calls.bus_coord_counts.lock().unwrap(), vec![3]); + } + + #[tokio::test] + async fn returns_the_range_reversed_when_going_backwards() { + let (interactor, calls) = build_interactor(build_line_group(20)); + + let segments = interactor + .get_train_route(1004, 1002, Some(1000)) + .await + .unwrap(); + + let ids: Vec = segments + .iter() + .filter_map(|s| s.station.as_ref().map(|st| st.id)) + .collect(); + assert_eq!(ids, vec![1004, 1003, 1002]); + assert_eq!( + calls.enriched_group_ids.lock().unwrap()[0], + vec![2002, 2003, 2004] + ); + } + + /// 付帯情報 (列車種別) が区間の駅に載っていること。載っていないと + /// 速度プロファイルが各停へ落ちる。 + #[tokio::test] + async fn keeps_train_type_driven_speed_profile() { + let (interactor, _) = build_interactor(build_line_group(20)); + + let segments = interactor + .get_train_route(1002, 1006, Some(1000)) + .await + .unwrap(); + + assert_eq!(segments.len(), 5); + // 端点は必ず停車、内側の奇数番は通過 + assert!(segments[0].stops); + assert!(!segments[1].stops); + assert!(segments[4].stops); + // 先頭は起点なので前駅からの距離は 0 + assert_eq!(segments[0].distance_from_previous, 0.0); + assert!(segments[1].distance_from_previous > 0.0); + + // 通過駅の無い同じ区間と比べる。通過駅が無ければ優等種別でも各停 + // 扱いになるので、速度に差が出るはず。単に max_speed が正である + // ことだけを見ると、種別が付与されなくてもテストが通ってしまう。 + let mut all_stops = build_line_group(20); + for station in all_stops.iter_mut() { + station.stop_condition = StopCondition::All; + station.pass = None; + } + let (local_interactor, _) = build_interactor(all_stops); + let local_segments = local_interactor + .get_train_route(1002, 1006, Some(1000)) + .await + .unwrap(); + + let top = |segments: &[model::TrainRouteSegment]| { + segments + .iter() + .map(|s| s.max_speed) + .fold(f64::MIN, f64::max) + }; + assert!( + top(&segments) > top(&local_segments), + "優等種別の速度が使われていない (優等 {} / 各停 {})", + top(&segments), + top(&local_segments) + ); + } + + /// 近傍バス停の探索半径には、駅グループの代表座標と各駅の座標の隔たりを + /// 足す。足さないと、代表からは 300m を超えるが同じグループの別の駅からは + /// 300m 以内、というバス停を取りこぼす。 + #[tokio::test] + async fn widens_the_bus_search_radius_by_the_station_group_offset() { + let mut stations = build_line_group(4); + // 3 駅目を 1 駅目と同じ駅グループにし、150m ほど離して置く + stations[2].station_g_cd = stations[0].station_g_cd; + stations[2].lat = stations[0].lat + 0.00135; + stations[2].lon = stations[0].lon; + let offset = haversine_distance( + stations[0].lat, + stations[0].lon, + stations[2].lat, + stations[2].lon, + ); + assert!(offset > 100.0, "前提: 2 駅は 100m 以上離れている"); + let (interactor, calls) = build_interactor(stations); + + interactor + .get_train_route(1000, 1003, Some(1000)) + .await + .unwrap(); + + let radii = calls.bus_radii.lock().unwrap(); + assert_eq!(radii.len(), 1); + assert!( + (radii[0] - (NEARBY_BUS_STOP_RADIUS_METERS + offset)).abs() < 1e-6, + "探索半径 {} が 300m + 代表座標からの隔たり {offset} になっていない", + radii[0] + ); + } + + #[tokio::test] + async fn errors_when_the_station_is_not_on_the_route() { + let (interactor, _) = build_interactor(build_line_group(20)); + + let err = interactor + .get_train_route(1002, 9999, Some(1000)) + .await + .unwrap_err(); + + assert!(matches!(err, UseCaseError::NotFound { .. })); + } + + #[tokio::test] + async fn errors_when_the_line_group_is_unspecified() { + let (interactor, _) = build_interactor(build_line_group(20)); + + let err = interactor + .get_train_route(1002, 1004, None) + .await + .unwrap_err(); + + assert!(matches!(err, UseCaseError::NotFound { .. })); + } + } }