From e596b7df6af5c3de1bbf5ce093736afc383bcfcd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 08:01:36 +0000 Subject: [PATCH 01/10] =?UTF-8?q?trainRoute=20=E3=81=AE=E3=83=9C=E3=83=88?= =?UTF-8?q?=E3=83=AB=E3=83=8D=E3=83=83=E3=82=AF=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要求区間の長さに関係なく系統全体を組み立てていた点と、駅ごとに全駅を走査 していた近傍バス停の検索を直す。 - 系統の停車駅は付帯情報を付ける前に取り、要求区間へ切り詰めてから付ける。 付帯情報の付与は駅ごとに独立しているので出力は変わらない。 O(系統の駅数) から O(要求区間の駅数) へ。 - 近傍検索に種別ごとのグリッド索引 (0.05 度のマス, CSR) を入れる。 nearest は半径を広げながら探し、その半径に limit 件そろった時点で打ち切る。 O(駅の総数) から O(半径内のマス) へ。stationsNearby にも効く。 - 近傍バス停は「全国の最寄り 50 件を作ってから 300m で捨てる」のをやめ、 半径を repository へ渡して内側だけを組み立てる。駅グループの代表座標と 各駅の座標の隔たりぶんを半径に足すので、採用され得るバス停は落ちない。 - 採用されないバス停の駅グループぶんまで路線を引いていたのをやめる。 - 事業者の一括取得の id_vec.contains を集合に置き換える。 - 距離が同着の場合の並びを station_cd 昇順に決め切る。 不安定ソート任せだったため、どの路線の行が先に来るか不定だった。 本番相当のデータ (41,148 駅 / うちバス停 30,000) での計測: | 要求区間 | 変更前 | 変更後 | | --- | --- | --- | | 250 駅 (東京〜出雲市) | 290.4ms | 14.7ms | | 13 駅 | 293.9ms | 2.59ms | | 3 駅 | 295.0ms | 1.39ms | trainRoute / lineGroupStations / lineStations の GraphQL 応答 718 件を 変更前後で突き合わせ、同着の並び以外が一致することを確認した。 make fmt / make clippy / make test / make check / data_validator は成功。 Worker crate の索引はネイティブでも動くため make test へ追加した。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- AGENTS.md | 6 +- Makefile | 4 +- src/index.rs | 442 +++++++++++++-- src/repository.rs | 18 +- .../domain/repository/station_repository.rs | 9 + stationapi/src/use_case/interactor/query.rs | 513 +++++++++++++++++- 6 files changed, 937 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a17ad26..ee83a8b9 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`. 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/src/index.rs b/src/index.rs index 34fd6c8d..a35bf8f3 100644 --- a/src/index.rs +++ b/src/index.rs @@ -453,12 +453,14 @@ fn build_companies() -> 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,7 +468,195 @@ 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; +/// 最初に見る半径 (km)。市街地ならこの範囲で近傍バス停 50 件がそろう。 +const INITIAL_SEARCH_RADIUS_KM: f64 = 1.0; +/// 半径の内側で件数が足りなかったときに広げる倍率。 +const SEARCH_RADIUS_GROWTH: f64 = 4.0; + +fn cell_index(deg: f64) -> i32 { + (deg / GRID_CELL_DEG).floor() as i32 +} + +/// 駅を緯度経度のマスへ割り当てた索引。 +/// +/// 全件走査だと 1 回の近傍検索で駅の総数ぶん距離を計算することになる。 +/// `trainRoute` は経路上の駅ごとに近傍バス停を引くため、経路が長いほど +/// (駅数 × 駅総数) で効いていた。マスに区切っておけば探索半径の内側だけで済む。 +/// +/// 添字は `stations()` のもの。CSR 形式で、`offsets[c]..offsets[c + 1]` が +/// マス c に属する駅の `items` 上の範囲を表す。 +struct Grid { + min_i: i32, + min_j: i32, + rows: usize, + cols: usize, + offsets: Vec, + items: Vec, +} + +impl Grid { + fn empty() -> Self { + Grid { + 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) + .map(|(i, _)| i as u32) + .collect(); + if members.is_empty() { + return Grid::empty(); + } + + let (mut min_i, mut max_i) = (i32::MAX, i32::MIN); + let (mut min_j, mut max_j) = (i32::MAX, i32::MIN); + for &m in &members { + let s = &stations()[m as usize]; + let (i, j) = (cell_index(s.lat), cell_index(s.lon)); + min_i = min_i.min(i); + max_i = max_i.max(i); + min_j = min_j.min(j); + max_j = max_j.max(j); + } + let rows = (max_i - min_i + 1) as usize; + let cols = (max_j - min_j + 1) as usize; + + // 度数分布 -> 累積和 -> 配置の 3 パスで CSR を組む + let mut offsets = vec![0u32; rows * cols + 1]; + let cell_of = |s: &StationRecord| -> usize { + let i = (cell_index(s.lat) - min_i) as usize; + let j = (cell_index(s.lon) - 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 { + 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), cell_index(lon + dlon_deg)) + }; + ( + cell_index(lat - dlat_deg), + cell_index(lat + dlat_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) のときは /// 鉄道を先・バスを後に並べたうえで距離順になる。 @@ -476,57 +666,106 @@ pub fn nearest( limit: usize, want: Option, ) -> Vec<(&'static StationRecord, f64)> { - nearest_inner(lat, lon, limit, want, true) + let Some(want) = want else { + // 種別未指定の並びは (transport_type 昇順, 距離昇順)。鉄道が全て + // バスより前に来るので、鉄道だけで limit 件そろえば以降は見なくてよい。 + let mut out = nearest_of_type(lat, lon, limit, TransportType::Rail as i32); + if out.len() < limit { + let rest = limit - out.len(); + out.extend(nearest_of_type(lat, lon, rest, TransportType::Bus as i32)); + } + 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(); + if radius_km < 0.0 { + 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 +1146,130 @@ 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(); + + let rank = move |s: &StationRecord| -> i32 { + if want.is_none() { + s.transport_type as i32 + } else { + 0 + } + }; + 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)) + }; + if limit < scored.len() { + scored.select_nth_unstable_by(limit, cmp); + scored.truncate(limit); + } + scored.sort_unstable_by(cmp); + 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()); + } + + /// 索引が返す距離は 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); + } + } +} diff --git a/src/repository.rs b/src/repository.rs index 44037503..0b04610c 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -322,20 +322,24 @@ 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 mut hits = index::within_radius(lat, lon, radius_km, want); + hits.truncate(limit); + for (record, _distance) in hits { let Some(line) = index::line_by_cd(record.line_cd) else { continue; }; @@ -930,10 +934,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..c71d2296 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -82,10 +82,15 @@ pub trait StationRepository: Send + Sync + 'static { }) .collect()) } + /// 各座標から `radius_meters` 以内のバス停を、近い順に最大 + /// `limit_per_station` 件返す。半径の外は呼び出し側でも採用されないため、 + /// ここで切っておく (全国の最寄り N 件を作ってから捨てると、駅数に比例して + /// 無駄が積み上がる)。 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, @@ -265,6 +270,7 @@ mod tests { &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + radius_meters: f64, ) -> Result, DomainError> { let mut result = Vec::new(); for &(source_g_cd, lat, lon) in coords { @@ -272,6 +278,9 @@ mod tests { .get_by_coordinates(lat, lon, Some(limit_per_station), Some(TransportType::Bus)) .await?; for stop in stops { + if stop.distance.is_some_and(|d| d > radius_meters) { + continue; + } result.push((source_g_cd, stop)); } } diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index d8968fe6..25d71308 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,434 @@ 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![]) + } + } + + /// 区間内の駅に種別を付ける。優等種別 (kind = 3) を返す。 + 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(3), + 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)); + // 東京駅から北へ 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); + // 通過駅があるので優等種別の速度が使われる (各停より速い) + let local = segments + .iter() + .map(|s| s.max_speed) + .fold(f64::MIN, f64::max); + assert!(local > 0.0); + // 先頭は起点なので前駅からの距離は 0 + assert_eq!(segments[0].distance_from_previous, 0.0); + assert!(segments[1].distance_from_previous > 0.0); + } + + /// 近傍バス停の探索半径には、駅グループの代表座標と各駅の座標の隔たりを + /// 足す。足さないと、代表からは 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 { .. })); + } + } } From c473833d9d3327419b162659f96ca26024a08524 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:35:34 +0000 Subject: [PATCH 02/10] =?UTF-8?q?stationsNearby=20=E3=81=AE=E4=B8=A6?= =?UTF-8?q?=E3=81=B3=E3=82=92=E8=B7=9D=E9=9B=A2=E9=A0=86=E3=81=AB=E7=9B=B4?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transportType を指定しない場合、第 1 キーが transport_type だったため 鉄道駅が全てバス停より先に来ていた。同じ場所にあるバス停より数 km 先の 鉄道駅が上位に出る状態で、近い順という仕様に反する。 移行前の SQL からこうなっていたもので、Workers 版はそれをそのまま 写していた。 ORDER BY CASE WHEN $4 IS NULL THEN COALESCE(s.transport_type, 0) ELSE 0 END, point(s.lat, s.lon) <-> point($1, $2) 種別ごとに上位 limit 件を引いてから距離で混ぜ直す。全体の上位 limit 件は 種別ごとの上位 limit 件の和集合に必ず含まれるため、これで全件走査と一致する。 本番相当のデータでの例 (36.453206, 140.013599 の最寄り 10 件): 変更前: Rail 460m, Rail 1846m, Rail 3145m, ... 変更後: Bus 0m, Bus 35m, Rail 460m, Bus 853m, Bus 1436m, ... Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- AGENTS.md | 2 +- docs/nearby-bus-stops.md | 2 ++ src/index.rs | 63 ++++++++++++++++++++++++++-------------- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee83a8b9..63b59823 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **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`. 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. +- **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/docs/nearby-bus-stops.md b/docs/nearby-bus-stops.md index 4475cf35..b99c87ae 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 | クエリ | 近傍バス停対応 | 備考 | diff --git a/src/index.rs b/src/index.rs index a35bf8f3..a0bed85f 100644 --- a/src/index.rs +++ b/src/index.rs @@ -656,10 +656,12 @@ fn grid_of(want: i32) -> &'static Grid { static EMPTY_GRID: OnceLock = OnceLock::new(); -/// 最近傍 limit 件を返す。路線を引けない駅は除く。 +/// 最近傍 limit 件を距離の昇順で返す。路線を引けない駅は除く。 /// -/// `want` は種別の絞り込み。未指定 (RailAndBus) のときは -/// 鉄道を先・バスを後に並べたうえで距離順になる。 +/// `want` は種別の絞り込み。未指定 (RailAndBus) でも並びは距離順で、 +/// 鉄道とバスを区別しない。移行前の SQL は `transport_type` を第 1 キーに +/// していたため、10m 先のバス停より 5km 先の鉄道駅が先に来ていた。 +/// `stationsNearby` は近い順が仕様なので、種別で先に分けない。 pub fn nearest( lat: f64, lon: f64, @@ -667,13 +669,16 @@ pub fn nearest( want: Option, ) -> Vec<(&'static StationRecord, f64)> { let Some(want) = want else { - // 種別未指定の並びは (transport_type 昇順, 距離昇順)。鉄道が全て - // バスより前に来るので、鉄道だけで limit 件そろえば以降は見なくてよい。 + // 全体の上位 limit 件は種別ごとの上位 limit 件の和集合に必ず含まれる + // (ある駅より近い駅が limit 件未満なら、同じ種別の中でも limit 件未満)。 + // 種別ごとに引いてから距離で混ぜ直す。 let mut out = nearest_of_type(lat, lon, limit, TransportType::Rail as i32); - if out.len() < limit { - let rest = limit - out.len(); - out.extend(nearest_of_type(lat, lon, rest, TransportType::Bus 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) @@ -1166,23 +1171,12 @@ mod tests { .map(|s| (s, haversine_km(lat, lon, s.lat, s.lon))) .collect(); - let rank = move |s: &StationRecord| -> i32 { - if want.is_none() { - s.transport_type as i32 - } else { - 0 - } - }; - 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)) - }; + // 種別を指定してもしなくても並びは距離順 (`stationsNearby` の仕様) if limit < scored.len() { - scored.select_nth_unstable_by(limit, cmp); + scored.select_nth_unstable_by(limit, by_distance_then_station_cd); scored.truncate(limit); } - scored.sort_unstable_by(cmp); + scored.sort_unstable_by(by_distance_then_station_cd); scored } @@ -1272,4 +1266,29 @@ mod tests { 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 + ); + } + } + } + } } From 99d8ad22316471c7ee62f619fd58c17f71469ff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:43:05 +0000 Subject: [PATCH 03/10] =?UTF-8?q?stationsNearby=20=E3=81=AE=20TransportTyp?= =?UTF-8?q?e=20=E9=A0=86=E3=82=BD=E3=83=BC=E3=83=88=E3=82=92=E5=85=83?= =?UTF-8?q?=E3=81=AB=E6=88=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 近い順が仕様という指摘を受けて transport_type の第 1 キーを外したが、 種別でのソートは残すという方針のため c473833 を差し戻す。 nearest の並びは transport_type 昇順・距離昇順に戻る。各種別の中では 距離順であることは変わらない。 This reverts commit c473833d9d3327419b162659f96ca26024a08524. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- AGENTS.md | 2 +- docs/nearby-bus-stops.md | 2 -- src/index.rs | 63 ++++++++++++++-------------------------- 3 files changed, 23 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 63b59823..ee83a8b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **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. +- **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`. 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/docs/nearby-bus-stops.md b/docs/nearby-bus-stops.md index b99c87ae..4475cf35 100644 --- a/docs/nearby-bus-stops.md +++ b/docs/nearby-bus-stops.md @@ -29,8 +29,6 @@ enum TransportType { | **Bus** | バス停のみを返す | | **RailAndBus** | 鉄道駅とバス停の両方を返す。`lines`配列にも近傍バス路線を含める | -**注**: `stationsNearby` の並びは常に近い順です。`transportType` を指定しない場合も鉄道とバスを分けず、距離だけで並べます。 - ## 対象API | クエリ | 近傍バス停対応 | 備考 | diff --git a/src/index.rs b/src/index.rs index a0bed85f..a35bf8f3 100644 --- a/src/index.rs +++ b/src/index.rs @@ -656,12 +656,10 @@ fn grid_of(want: i32) -> &'static Grid { static EMPTY_GRID: OnceLock = OnceLock::new(); -/// 最近傍 limit 件を距離の昇順で返す。路線を引けない駅は除く。 +/// 最近傍 limit 件を返す。路線を引けない駅は除く。 /// -/// `want` は種別の絞り込み。未指定 (RailAndBus) でも並びは距離順で、 -/// 鉄道とバスを区別しない。移行前の SQL は `transport_type` を第 1 キーに -/// していたため、10m 先のバス停より 5km 先の鉄道駅が先に来ていた。 -/// `stationsNearby` は近い順が仕様なので、種別で先に分けない。 +/// `want` は種別の絞り込み。未指定 (RailAndBus) のときは +/// 鉄道を先・バスを後に並べたうえで距離順になる。 pub fn nearest( lat: f64, lon: f64, @@ -669,16 +667,13 @@ pub fn nearest( want: Option, ) -> Vec<(&'static StationRecord, f64)> { let Some(want) = want else { - // 全体の上位 limit 件は種別ごとの上位 limit 件の和集合に必ず含まれる - // (ある駅より近い駅が limit 件未満なら、同じ種別の中でも limit 件未満)。 - // 種別ごとに引いてから距離で混ぜ直す。 + // 種別未指定の並びは (transport_type 昇順, 距離昇順)。鉄道が全て + // バスより前に来るので、鉄道だけで 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); + if out.len() < limit { + let rest = limit - out.len(); + out.extend(nearest_of_type(lat, lon, rest, TransportType::Bus as i32)); } - out.sort_unstable_by(by_distance_then_station_cd); return out; }; nearest_of_type(lat, lon, limit, want) @@ -1171,12 +1166,23 @@ mod tests { .map(|s| (s, haversine_km(lat, lon, s.lat, s.lon))) .collect(); - // 種別を指定してもしなくても並びは距離順 (`stationsNearby` の仕様) + let rank = move |s: &StationRecord| -> i32 { + if want.is_none() { + s.transport_type as i32 + } else { + 0 + } + }; + 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)) + }; if limit < scored.len() { - scored.select_nth_unstable_by(limit, by_distance_then_station_cd); + scored.select_nth_unstable_by(limit, cmp); scored.truncate(limit); } - scored.sort_unstable_by(by_distance_then_station_cd); + scored.sort_unstable_by(cmp); scored } @@ -1266,29 +1272,4 @@ mod tests { 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 - ); - } - } - } - } } From e234e4ec52d065327c0e3d3e80e276d159559a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:43:52 +0000 Subject: [PATCH 04/10] =?UTF-8?q?stationsNearby=20=E3=81=AE=E4=B8=A6?= =?UTF-8?q?=E3=81=B3=E3=82=92=E8=B7=9D=E9=9B=A2=E9=A0=86=E3=81=AB=E6=88=BB?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方針の確認により、ソートは距離のみとする。99d8ad2 を差し戻し、 c473833 の内容に戻す。 transportType 未指定でも鉄道とバスを分けず、距離だけで並べる。 種別ごとに上位 limit 件を引いてから距離で混ぜ直す (全体の上位 limit 件は 種別ごとの上位 limit 件の和集合に必ず含まれるため全件走査と一致する)。 This reverts commit 99d8ad2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- AGENTS.md | 2 +- docs/nearby-bus-stops.md | 2 ++ src/index.rs | 63 ++++++++++++++++++++++++++-------------- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee83a8b9..63b59823 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **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`. 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. +- **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/docs/nearby-bus-stops.md b/docs/nearby-bus-stops.md index 4475cf35..b99c87ae 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 | クエリ | 近傍バス停対応 | 備考 | diff --git a/src/index.rs b/src/index.rs index a35bf8f3..a0bed85f 100644 --- a/src/index.rs +++ b/src/index.rs @@ -656,10 +656,12 @@ fn grid_of(want: i32) -> &'static Grid { static EMPTY_GRID: OnceLock = OnceLock::new(); -/// 最近傍 limit 件を返す。路線を引けない駅は除く。 +/// 最近傍 limit 件を距離の昇順で返す。路線を引けない駅は除く。 /// -/// `want` は種別の絞り込み。未指定 (RailAndBus) のときは -/// 鉄道を先・バスを後に並べたうえで距離順になる。 +/// `want` は種別の絞り込み。未指定 (RailAndBus) でも並びは距離順で、 +/// 鉄道とバスを区別しない。移行前の SQL は `transport_type` を第 1 キーに +/// していたため、10m 先のバス停より 5km 先の鉄道駅が先に来ていた。 +/// `stationsNearby` は近い順が仕様なので、種別で先に分けない。 pub fn nearest( lat: f64, lon: f64, @@ -667,13 +669,16 @@ pub fn nearest( want: Option, ) -> Vec<(&'static StationRecord, f64)> { let Some(want) = want else { - // 種別未指定の並びは (transport_type 昇順, 距離昇順)。鉄道が全て - // バスより前に来るので、鉄道だけで limit 件そろえば以降は見なくてよい。 + // 全体の上位 limit 件は種別ごとの上位 limit 件の和集合に必ず含まれる + // (ある駅より近い駅が limit 件未満なら、同じ種別の中でも limit 件未満)。 + // 種別ごとに引いてから距離で混ぜ直す。 let mut out = nearest_of_type(lat, lon, limit, TransportType::Rail as i32); - if out.len() < limit { - let rest = limit - out.len(); - out.extend(nearest_of_type(lat, lon, rest, TransportType::Bus 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) @@ -1166,23 +1171,12 @@ mod tests { .map(|s| (s, haversine_km(lat, lon, s.lat, s.lon))) .collect(); - let rank = move |s: &StationRecord| -> i32 { - if want.is_none() { - s.transport_type as i32 - } else { - 0 - } - }; - 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)) - }; + // 種別を指定してもしなくても並びは距離順 (`stationsNearby` の仕様) if limit < scored.len() { - scored.select_nth_unstable_by(limit, cmp); + scored.select_nth_unstable_by(limit, by_distance_then_station_cd); scored.truncate(limit); } - scored.sort_unstable_by(cmp); + scored.sort_unstable_by(by_distance_then_station_cd); scored } @@ -1272,4 +1266,29 @@ mod tests { 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 + ); + } + } + } + } } From 5997a22b67bdbfa7f2ae5845b692ce02224c3abb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:50:44 +0000 Subject: [PATCH 05/10] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AE=E5=AF=BE=E5=BF=9C:=20=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E3=81=AE=E9=A0=91=E5=81=A5=E6=80=A7=E3=81=A8=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=AE=E5=AE=9F=E5=8A=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit の指摘のうち、挙動を変えずに直せるものを反映する。 - Grid の座標を検証し、マス数に上限を設ける。offsets は外接矩形に比例して 確保するため、GTFS 由来の外れ値が 1 件混ざるだけで確保量が跳ね上がる (lat=1e9 のような値では確保に失敗して全リクエストが落ちる)。NaN・無限大・ WGS84 の範囲外を索引から外し、上限を超える場合はマスを粗くして収める。 索引の役目は候補を絞ることなので、粗くしても返す結果は変わらない。 - テスト用モックの半径フィルタが機能していなかった。get_by_coordinates が distance に入れるのは度で測ったユークリッド距離で、メートルの半径とは 比較できず素通りしていた。距離を測り直し、trait の契約どおり半径で絞って から件数を切る。 - keeps_train_type_driven_speed_profile が種別の反映を検証していなかった。 max_speed が正であることしか見ておらず、種別が付かなくても通る。通過駅の 無い同じ区間と比べる形に変え、フィクスチャの路線種別を在来線、種別を特急に する (速度に効くのは LimitedExpress と HighSpeedRapid だけで、新幹線では 路線側の上限が種別の下限を上回るため差が出ない)。 - within_radius の直接テストを追加する。半径の境界、半径 0、負の半径、 距離の昇順、全件走査との一致を見る。 make fmt / make clippy / make test (419 件) / make check は成功。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- src/index.rs | 159 ++++++++++++++++-- .../domain/repository/station_repository.rs | 30 +++- stationapi/src/use_case/interactor/query.rs | 45 +++-- 3 files changed, 203 insertions(+), 31 deletions(-) diff --git a/src/index.rs b/src/index.rs index a0bed85f..3d1f0a34 100644 --- a/src/index.rs +++ b/src/index.rs @@ -472,13 +472,24 @@ pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { /// 細かくするとマスの数 (= 索引の大きさ) が増え、粗くすると 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; -fn cell_index(deg: f64) -> i32 { - (deg / GRID_CELL_DEG).floor() as i32 +/// 索引に載せられる座標か。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 } /// 駅を緯度経度のマスへ割り当てた索引。 @@ -490,6 +501,7 @@ fn cell_index(deg: f64) -> i32 { /// 添字は `stations()` のもの。CSR 形式で、`offsets[c]..offsets[c + 1]` が /// マス c に属する駅の `items` 上の範囲を表す。 struct Grid { + cell_deg: f64, min_i: i32, min_j: i32, rows: usize, @@ -501,6 +513,7 @@ struct Grid { impl Grid { fn empty() -> Self { Grid { + cell_deg: GRID_CELL_DEG, min_i: 0, min_j: 0, rows: 0, @@ -519,30 +532,45 @@ impl Grid { .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(); } - let (mut min_i, mut max_i) = (i32::MAX, i32::MIN); - let (mut min_j, mut max_j) = (i32::MAX, i32::MIN); - for &m in &members { - let s = &stations()[m as usize]; - let (i, j) = (cell_index(s.lat), cell_index(s.lon)); - min_i = min_i.min(i); - max_i = max_i.max(i); - min_j = min_j.min(j); - max_j = max_j.max(j); + // 外接矩形がマス数の上限に収まるまでマスを粗くする。座標は 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); } - let rows = (max_i - min_i + 1) as usize; - let cols = (max_j - min_j + 1) as usize; // 度数分布 -> 累積和 -> 配置の 3 パスで CSR を組む let mut offsets = vec![0u32; rows * cols + 1]; let cell_of = |s: &StationRecord| -> usize { - let i = (cell_index(s.lat) - min_i) as usize; - let j = (cell_index(s.lon) - min_j) as 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 { @@ -560,6 +588,7 @@ impl Grid { } Grid { + cell_deg, min_i, min_j, rows, @@ -590,11 +619,14 @@ impl Grid { 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), cell_index(lon + dlon_deg)) + ( + cell_index(lon - dlon_deg, self.cell_deg), + cell_index(lon + dlon_deg, self.cell_deg), + ) }; ( - cell_index(lat - dlat_deg), - cell_index(lat + dlat_deg), + cell_index(lat - dlat_deg, self.cell_deg), + cell_index(lat + dlat_deg, self.cell_deg), j0, j1, ) @@ -1253,6 +1285,97 @@ mod tests { 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()); + } + + /// 索引に載せられない座標を弾く。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() { diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index c71d2296..8865a1f8 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -266,21 +266,45 @@ 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> { + /// 球面距離 (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 { - if stop.distance.is_some_and(|d| d > radius_meters) { + let mut taken = 0u32; + for mut stop in stops { + if taken >= limit_per_station { + break; + } + let meters = haversine_meters(lat, lon, stop.lat, stop.lon); + if meters > radius_meters { continue; } + stop.distance = Some(meters); + taken += 1; result.push((source_g_cd, stop)); } } diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 25d71308..91e1b6cb 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -5641,7 +5641,8 @@ mod tests { } } - /// 区間内の駅に種別を付ける。優等種別 (kind = 3) を返す。 + /// 区間内の駅に種別を付ける。速度プロファイルを各停から引き上げるのは + /// LimitedExpress (4) と HighSpeedRapid (5) だけなので、特急を返す。 struct StubTrainTypeRepository; #[async_trait::async_trait] @@ -5659,14 +5660,14 @@ mod tests { type_cd: Some(1), line_group_cd: Some(1000), pass: None, - type_name: "急行".to_string(), - type_name_k: "キュウコウ".to_string(), + 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(3), + kind: Some(4), line: None, lines: vec![], }) @@ -5734,6 +5735,9 @@ mod tests { .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; @@ -5814,15 +5818,36 @@ mod tests { assert!(segments[0].stops); assert!(!segments[1].stops); assert!(segments[4].stops); - // 通過駅があるので優等種別の速度が使われる (各停より速い) - let local = segments - .iter() - .map(|s| s.max_speed) - .fold(f64::MIN, f64::max); - assert!(local > 0.0); // 先頭は起点なので前駅からの距離は 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) + ); } /// 近傍バス停の探索半径には、駅グループの代表座標と各駅の座標の隔たりを From ea6805417cc58b888bc2212ac73d32c01e876424 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:59:41 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=E5=BA=A7=E6=A8=99=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E3=81=8C=20NaN=20=E3=81=A7=E8=BF=94=E3=82=89=E3=81=AA=E3=81=8F?= =?UTF-8?q?=E3=81=AA=E3=82=8B=E4=B8=8D=E5=85=B7=E5=90=88=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lat が NaN だと covers_all が永久に false になり、nearest_of_type が半径を 無限大まで広げ続けても抜けられない。1 リクエストで Worker が固まる。 NaN.min(90.0) が 90.0 を返すため cos_phi が 0 付近になり経度は全周になるが、 緯度側の cell_index(NaN) が 0 に落ちるため外接矩形を覆ったと判定されない。 全件走査だった頃は起きなかった、索引の導入で入った退行。 nearest と within_radius の入口で、索引に載せられない座標 (NaN・無限大・ WGS84 の範囲外) を弾く。回帰テストを追加した (修正前はこのテストが終わらない)。 あわせてテスト用モックの並びを直す。get_by_coordinates が返す順は度で 測ったユークリッド距離順で、緯度の高い地点では球面距離順と一致しない。 件数を切る前に測り直した距離で並べ直す。 make fmt / make clippy / make test (420 件) / make check は成功。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- src/index.rs | 33 ++++++++++++++++++- .../domain/repository/station_repository.rs | 33 +++++++++++-------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/index.rs b/src/index.rs index 3d1f0a34..a1bd0546 100644 --- a/src/index.rs +++ b/src/index.rs @@ -700,6 +700,12 @@ pub fn nearest( limit: usize, want: Option, ) -> Vec<(&'static StationRecord, f64)> { + // 索引に載せられない座標では探索を打ち切れない。lat が NaN だと + // covers_all が永久に false のままで、半径を無限大まで広げ続けても + // 抜けられない (リクエストが返らなくなる)。入口で弾く。 + if !indexable_coords(lat, lon) { + return Vec::new(); + } let Some(want) = want else { // 全体の上位 limit 件は種別ごとの上位 limit 件の和集合に必ず含まれる // (ある駅より近い駅が limit 件未満なら、同じ種別の中でも limit 件未満)。 @@ -743,7 +749,8 @@ pub fn within_radius( want: i32, ) -> Vec<(&'static StationRecord, f64)> { let mut out: Vec<(&'static StationRecord, f64)> = Vec::new(); - if radius_km < 0.0 { + // `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| { @@ -1351,6 +1358,30 @@ mod tests { 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] diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index 8865a1f8..6b8b5a53 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -294,19 +294,26 @@ mod tests { let stops = self .get_by_coordinates(lat, lon, None, Some(TransportType::Bus)) .await?; - let mut taken = 0u32; - for mut stop in stops { - if taken >= limit_per_station { - break; - } - let meters = haversine_meters(lat, lon, stop.lat, stop.lon); - if meters > radius_meters { - continue; - } - stop.distance = Some(meters); - taken += 1; - 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(); + within.sort_by(|a, b| { + a.distance + .partial_cmp(&b.distance) + .unwrap_or(std::cmp::Ordering::Equal) + }); + within.truncate(limit_per_station as usize); + result.extend(within.into_iter().map(|stop| (source_g_cd, stop))); } Ok(result) } From c7bdef513a29465c7275e88a1cb525c40899fd87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:03:49 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E7=94=A8?= =?UTF-8?q?=E3=83=A2=E3=83=83=E3=82=AF=E3=81=AE=E5=90=8C=E8=B7=9D=E9=9B=A2?= =?UTF-8?q?=E6=99=82=E3=81=AE=E4=B8=A6=E3=81=B3=E3=82=92=20station=5Fcd=20?= =?UTF-8?q?=E3=81=A7=E6=B1=BA=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 元の並びは HashMap の反復順なので、同距離の順序を距離だけに任せると 件数を切ったときにどのバス停が残るか実行ごとに変わる。索引側の by_distance_then_station_cd と同じく station_cd の昇順で決め切る。 make fmt / make clippy / make test / make check は成功。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- stationapi/src/domain/repository/station_repository.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index 6b8b5a53..e28c567f 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -307,10 +307,14 @@ mod tests { }) }) .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))); From a28cbf53dae9a9cd53a8081d3e91068279b50c79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:09:06 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=E8=BF=91=E5=82=8D=E3=83=90=E3=82=B9?= =?UTF-8?q?=E5=81=9C=E6=A4=9C=E7=B4=A2=E3=81=AE=E5=8D=8A=E5=BE=84=E3=81=AE?= =?UTF-8?q?=E6=89=B1=E3=81=84=E3=81=AB=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_bus_stops_near_stations を検証するテストが 1 つも無く、半径の扱いは レビューで 2 度指摘を受けて直した箇所だった。挙動を固定する。 - 半径の外のバス停を含めないこと、距離をメートルで入れること - 件数の上限は半径で絞ったあとに掛けること (先に切ると半径の外の駅が 枠を埋めた分だけ返る件数が減る) - 同距離の並びが station_cd の昇順であること - 座標ごとにまとまり、その中では距離順であること make fmt / make clippy / make test (423 件) / make check は成功。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- .../domain/repository/station_repository.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index e28c567f..f340a092 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -499,6 +499,111 @@ 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)]); + } + #[tokio::test] async fn test_find_by_id_existing() { let repo = MockStationRepository::new(); From 2f5cedd5aea81ecb5ab85200532c3b480ee42216 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:13:22 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=E5=8D=8A=E5=BE=84=E3=81=8C=E6=9C=89?= =?UTF-8?q?=E5=8A=B9=E3=81=A7=E3=81=AA=E3=81=84=E5=A0=B4=E5=90=88=E3=81=AE?= =?UTF-8?q?=E6=89=B1=E3=81=84=E3=82=92=20trait=20=E3=81=AE=E5=A5=91?= =?UTF-8?q?=E7=B4=84=E3=81=A8=E3=81=97=E3=81=A6=E6=98=8E=E8=A8=98=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit radius_meters が無限大だと meters <= radius_meters が常に成立するため、 テスト用モックは全バス停を返していた。本番実装 (index::within_radius) は 非有限・負の半径で空を返すので、同じ trait の実装同士で結果が食い違う。 trait のドキュメントに「非有限 (NaN / 無限大) と負の半径は空を返す」と 書き、モック側にも同じ判定を入れる。無限大・負の無限大・NaN・負値の 4 通りで空になること、有限の半径では従来どおり返ることをテストする。 make fmt / make clippy / make test (424 件) / make check は成功。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016LkyHhHy1ND4wYZpyPC2wW --- .../domain/repository/station_repository.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index f340a092..fc1a69b3 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -86,6 +86,10 @@ pub trait StationRepository: Send + Sync + 'static { /// `limit_per_station` 件返す。半径の外は呼び出し側でも採用されないため、 /// ここで切っておく (全国の最寄り N 件を作ってから捨てると、駅数に比例して /// 無駄が積み上がる)。 + /// + /// 半径が有限でない (`NaN` / 無限大) 場合と負の場合は空を返す。無限大を + /// 距離の比較にそのまま使うと全件が半径内と判定されるため、実装ごとに + /// 結果が食い違わないようここで決めておく。 async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], // (station_g_cd, lat, lon) @@ -278,6 +282,11 @@ mod tests { 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; @@ -604,6 +613,32 @@ mod tests { 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(); From 092b4183dc6f8076c738900e1fa7ede9fbced3d3 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 24 Aug 2026 10:19:09 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=E8=BF=91=E5=82=8D=E3=83=90=E3=82=B9?= =?UTF-8?q?=E5=81=9C=E3=81=AE=E7=B5=9E=E3=82=8A=E8=BE=BC=E3=81=BF=E3=82=92?= =?UTF-8?q?=E4=BB=B6=E6=95=B0=E3=81=AE=E4=B8=8A=E9=99=90=E3=82=88=E3=82=8A?= =?UTF-8?q?=E5=85=88=E3=81=AB=E8=A1=8C=E3=81=86=20(#1648)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 有効な路線を持つバス停だけを数えて上限で打ち切る。上限を先に掛けると、 路線を引けないバス停や廃止路線のバス停が枠を埋めたぶんだけ、返る近傍バス 路線が減っていた。 移行前の SQL から続く挙動で、都心の駅では 300m 以内のバス停行が上限の 50 件を超えるため、無効な行が上位を占めると近傍バス路線が欠落する。 遅延評価になるため to_entity の呼び出しは採用される件数までに減る。 --- docs/nearby-bus-stops.md | 2 +- src/index.rs | 2 +- src/repository.rs | 30 ++++++++++++++---------------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/nearby-bus-stops.md b/docs/nearby-bus-stops.md index b99c87ae..6f370751 100644 --- a/docs/nearby-bus-stops.md +++ b/docs/nearby-bus-stops.md @@ -125,5 +125,5 @@ async fn get_nearby_bus_lines(&self, ref_lat: f64, ref_lon: f64) -> Result