use std::collections::{HashMap, HashSet};
use chrono::{Datelike, NaiveDate, Weekday};
use gtfs_structures::{Availability, Exception, Gtfs, PickupDropOffType};
use jiff::civil::Date;
use rstar::{AABB, PointDistance, RTree, RTreeObject};
use smallvec::SmallVec;
use crate::{Duration, RouteIdx, SecondOfDay, StopIdx, Timetable, TripIdx};
const EARTH_RADIUS_M: f64 = 6_371_000.0;
#[derive(Clone, Copy, Debug)]
struct ProjectedStop {
pos: [f64; 2],
idx: StopIdx,
}
impl RTreeObject for ProjectedStop {
type Envelope = AABB<[f64; 2]>;
fn envelope(&self) -> Self::Envelope {
AABB::from_point(self.pos)
}
}
impl PointDistance for ProjectedStop {
fn distance_2(&self, point: &[f64; 2]) -> f64 {
let dx = self.pos[0] - point[0];
let dy = self.pos[1] - point[1];
dx * dx + dy * dy
}
}
fn is_service_active(gtfs: &Gtfs, service_id: &str, date: NaiveDate) -> bool {
if let Some(cdates) = gtfs.calendar_dates.get(service_id)
&& let Some(cd) = cdates.iter().find(|cd| cd.date == date)
{
return matches!(cd.exception_type, Exception::Added);
}
if let Some(cal) = gtfs.calendar.get(service_id) {
if date < cal.start_date || date > cal.end_date {
return false;
}
return match date.weekday() {
Weekday::Mon => cal.monday,
Weekday::Tue => cal.tuesday,
Weekday::Wed => cal.wednesday,
Weekday::Thu => cal.thursday,
Weekday::Fri => cal.friday,
Weekday::Sat => cal.saturday,
Weekday::Sun => cal.sunday,
};
}
false
}
fn jiff_to_chrono(d: Date) -> NaiveDate {
NaiveDate::from_ymd_opt(d.year() as i32, d.month() as u32, d.day() as u32)
.expect("jiff::civil::Date guarantees a valid (year, month, day) triple")
}
const TYPICAL_ROUTES_PER_STOP: usize = 8;
const TYPICAL_TRANSFERS_PER_STOP: usize = 4;
const DEFAULT_TRANSFER_TIME: Duration = Duration(300);
#[derive(thiserror::Error, Debug)]
pub enum GtfsError {
#[error(
"stop {stop} (referenced by trip {trip} on route {route}, agency {}) not found",
agency.as_deref().unwrap_or("?"),
)]
MissingStop {
stop: String,
trip: String,
route: String,
agency: Option<String>,
},
#[error(
"trip {trip} on route {route} (agency {}) has no stop_times",
agency.as_deref().unwrap_or("?"),
)]
MissingStopTimes {
trip: String,
route: String,
agency: Option<String>,
},
#[error(
"stop_time has no departure_time: trip {trip} on route {route} (agency {}), stop {stop}",
agency.as_deref().unwrap_or("?"),
)]
MissingDepartureTime {
trip: String,
route: String,
agency: Option<String>,
stop: String,
},
#[error(
"base_date + {days_added} days falls outside jiff's representable range (base = {base})"
)]
DateOutOfRange {
base: Date,
days_added: u32,
},
}
type GtfsResult<T> = std::result::Result<T, GtfsError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OvernightDays(pub u8);
impl OvernightDays {
pub fn get(self) -> u8 {
self.0
}
}
pub struct GtfsTimetable {
stop_ids: Vec<String>,
route_ids: Vec<String>,
trip_ids: Vec<String>,
stop_by_id: HashMap<String, StopIdx>,
route_by_id: HashMap<String, RouteIdx>,
routes_by_gtfs_id: HashMap<String, SmallVec<[RouteIdx; 2]>>,
trip_by_id: HashMap<String, TripIdx>,
routes_for_stop: Vec<SmallVec<[(RouteIdx, u32); TYPICAL_ROUTES_PER_STOP]>>,
stops_for_route: Vec<Vec<StopIdx>>,
trips_for_route: Vec<Vec<TripIdx>>,
arrival_times: Vec<Vec<Vec<SecondOfDay>>>,
departure_times: Vec<Vec<Vec<SecondOfDay>>>,
route_for_trip: Vec<(RouteIdx, usize)>,
footpaths_for_stops: Vec<SmallVec<[StopIdx; TYPICAL_TRANSFERS_PER_STOP]>>,
transfer_times: HashMap<(StopIdx, StopIdx), Duration>,
no_pickup: HashSet<(TripIdx, u32)>,
no_drop_off: HashSet<(TripIdx, u32)>,
inaccessible_trips: HashSet<TripIdx>,
inaccessible_stops: HashSet<StopIdx>,
transfers_closed: bool,
station_children: HashMap<String, Vec<(StopIdx, Duration)>>,
}
impl GtfsTimetable {
pub fn new(gtfs: &Gtfs, service_date: Date) -> GtfsResult<Self> {
Self::build(gtfs, service_date, 0)
}
pub fn new_with_overnight_days(
gtfs: &Gtfs,
service_date: Date,
days: OvernightDays,
) -> GtfsResult<Self> {
Self::build(gtfs, service_date, days.get())
}
fn build(gtfs: &Gtfs, base_date: Date, n_overnight_days: u8) -> GtfsResult<Self> {
let mut day_dates: Vec<NaiveDate> = Vec::with_capacity(usize::from(n_overnight_days) + 1);
for d in 0..=n_overnight_days {
let day = base_date
.checked_add(jiff::Span::new().days(i64::from(d)))
.map_err(|_| GtfsError::DateOutOfRange {
base: base_date,
days_added: u32::from(d),
})?;
day_dates.push(jiff_to_chrono(day));
}
let mut stop_ids: Vec<String> = Vec::with_capacity(gtfs.stops.len());
let mut stop_by_id: HashMap<String, StopIdx> = HashMap::with_capacity(gtfs.stops.len());
let mut inaccessible_stops: HashSet<StopIdx> = HashSet::new();
for (stop_id, stop) in >fs.stops {
let idx = StopIdx::new(stop_ids.len() as u32);
stop_ids.push(stop_id.clone());
stop_by_id.insert(stop_id.clone(), idx);
if matches!(stop.wheelchair_boarding, Availability::NotAvailable) {
inaccessible_stops.insert(idx);
}
}
type GroupKey<'g> = (&'g str, Vec<StopIdx>);
type GroupEntries<'g> = Vec<(&'g str, u8)>;
let mut groups: std::collections::BTreeMap<GroupKey<'_>, GroupEntries<'_>> =
std::collections::BTreeMap::new();
for (day_offset, &day_chrono) in day_dates.iter().enumerate() {
let day_offset = day_offset as u8;
for (trip_id, trip) in >fs.trips {
if !is_service_active(gtfs, &trip.service_id, day_chrono) {
continue;
}
let route_id = trip.route_id.clone();
let agency_id = gtfs
.routes
.get(&trip.route_id)
.and_then(|r| r.agency_id.clone());
if trip.stop_times.is_empty() {
return Err(GtfsError::MissingStopTimes {
trip: trip_id.clone(),
route: route_id,
agency: agency_id,
});
}
let mut stop_seq: Vec<StopIdx> = Vec::with_capacity(trip.stop_times.len());
for st in &trip.stop_times {
let raw_id = st.stop.id.as_str();
let stop_idx =
*stop_by_id
.get(raw_id)
.ok_or_else(|| GtfsError::MissingStop {
stop: raw_id.to_owned(),
trip: trip_id.clone(),
route: route_id.clone(),
agency: agency_id.clone(),
})?;
if st.departure_time.is_none() {
return Err(GtfsError::MissingDepartureTime {
trip: trip_id.clone(),
route: route_id.clone(),
agency: agency_id.clone(),
stop: raw_id.to_owned(),
});
}
stop_seq.push(stop_idx);
}
groups
.entry((trip.route_id.as_str(), stop_seq))
.or_default()
.push((trip_id.as_str(), day_offset));
}
}
let mut route_ids: Vec<String> = Vec::new();
let mut stops_for_route: Vec<Vec<StopIdx>> = Vec::new();
let mut trips_for_route: Vec<Vec<TripIdx>> = Vec::new();
let mut arrival_times: Vec<Vec<Vec<SecondOfDay>>> = Vec::new();
let mut departure_times: Vec<Vec<Vec<SecondOfDay>>> = Vec::new();
let mut route_for_trip: Vec<(RouteIdx, usize)> = Vec::with_capacity(gtfs.trips.len());
let mut no_pickup: HashSet<(TripIdx, u32)> = HashSet::new();
let mut no_drop_off: HashSet<(TripIdx, u32)> = HashSet::new();
let mut inaccessible_trips: HashSet<TripIdx> = HashSet::new();
let mut trip_ids: Vec<String> = Vec::new();
let mut trip_by_id: HashMap<String, TripIdx> = HashMap::new();
let mut route_by_id: HashMap<String, RouteIdx> = HashMap::new();
let mut routes_by_gtfs_id: HashMap<String, SmallVec<[RouteIdx; 2]>> = HashMap::new();
let mut routes_for_stop: Vec<SmallVec<[(RouteIdx, u32); TYPICAL_ROUTES_PER_STOP]>> =
vec![SmallVec::new(); stop_ids.len()];
for ((gtfs_route_id, stop_seq), trips) in groups {
let mut trips_with_schedules: Vec<(&str, u8, &[gtfs_structures::StopTime])> =
trips
.into_iter()
.map(|(trip_id, day_offset)| {
let trip = gtfs.get_trip(trip_id).expect(
"trip_id is a key in gtfs.trips (groups was built by iterating gtfs.trips earlier in new())",
);
(trip_id, day_offset, trip.stop_times.as_slice())
})
.collect();
trips_with_schedules.sort_by_key(|(_, day_offset, st)| {
let raw_dep = st[0].departure_time.expect(
"first stop_time.departure_time is required by the GtfsError::MissingDepartureTime check earlier in new()",
);
shift_dep(raw_dep, *day_offset)
});
for sub_group in split_non_overtaking(&trips_with_schedules) {
let route_idx = RouteIdx::new(route_ids.len() as u32);
route_ids.push(gtfs_route_id.to_owned());
stops_for_route.push(stop_seq.clone());
let mut sub_trip_idxs: Vec<TripIdx> = Vec::with_capacity(sub_group.len());
for (trip_id, _day_offset) in &sub_group {
let trip_idx = TripIdx::new(trip_ids.len() as u32);
trip_ids.push((*trip_id).to_owned());
trip_by_id.entry((*trip_id).to_owned()).or_insert(trip_idx);
sub_trip_idxs.push(trip_idx);
debug_assert_eq!(route_for_trip.len(), trip_idx.idx());
route_for_trip.push((route_idx, sub_trip_idxs.len() - 1));
}
let n_stops_in_route = stop_seq.len();
let n_trips_in_route = sub_group.len();
let mut arr_table: Vec<Vec<SecondOfDay>> =
vec![vec![SecondOfDay::MAX; n_trips_in_route]; n_stops_in_route];
let mut dep_table: Vec<Vec<SecondOfDay>> =
vec![vec![SecondOfDay::MAX; n_trips_in_route]; n_stops_in_route];
for (trip_pos, (trip_id, day_offset)) in sub_group.iter().enumerate() {
let trip = gtfs.get_trip(trip_id).expect(
"trip_id originated from gtfs.trips and survived service-day filtering",
);
let trip_idx = sub_trip_idxs[trip_pos];
if matches!(trip.wheelchair_accessible, Availability::NotAvailable) {
inaccessible_trips.insert(trip_idx);
}
for (stop_pos, st) in trip.stop_times.iter().enumerate() {
if let Some(a) = st.arrival_time {
arr_table[stop_pos][trip_pos] = shift_dep(a, *day_offset);
}
let d = st.departure_time.expect(
"stop_time.departure_time is required by the GtfsError::MissingDepartureTime check earlier in new()",
);
dep_table[stop_pos][trip_pos] = shift_dep(d, *day_offset);
if matches!(st.pickup_type, PickupDropOffType::NotAvailable) {
no_pickup.insert((trip_idx, stop_pos as u32));
}
if matches!(st.drop_off_type, PickupDropOffType::NotAvailable) {
no_drop_off.insert((trip_idx, stop_pos as u32));
}
}
}
arrival_times.push(arr_table);
departure_times.push(dep_table);
trips_for_route.push(sub_trip_idxs);
route_by_id
.entry(gtfs_route_id.to_owned())
.or_insert(route_idx);
routes_by_gtfs_id
.entry(gtfs_route_id.to_owned())
.or_default()
.push(route_idx);
for (pos, &stop_idx) in stop_seq.iter().enumerate() {
let entry = &mut routes_for_stop[stop_idx.idx()];
if !entry.iter().any(|(r, _)| *r == route_idx) {
entry.push((route_idx, pos as u32));
}
}
}
}
let mut footpaths_for_stops: Vec<SmallVec<[StopIdx; TYPICAL_TRANSFERS_PER_STOP]>> =
vec![SmallVec::new(); stop_ids.len()];
let mut transfer_times: HashMap<(StopIdx, StopIdx), Duration> = HashMap::new();
for (stop_id, stop) in >fs.stops {
if stop.transfers.is_empty() {
continue;
}
let from_idx = *stop_by_id
.get(stop_id.as_str())
.expect("every stop_id was interned by the stops loop at the start of new()");
let usable = stop
.transfers
.iter()
.filter(|t| t.transfer_type != gtfs_structures::TransferType::Impossible);
for t in usable {
let Some(&to_idx) = stop_by_id.get(t.to_stop_id.as_str()) else {
continue;
};
footpaths_for_stops[from_idx.idx()].push(to_idx);
if let Some(min) = t.min_transfer_time {
transfer_times.insert((from_idx, to_idx), Duration(min));
}
}
}
let mut station_children: HashMap<String, Vec<(StopIdx, Duration)>> = HashMap::new();
for (stop_id, stop) in >fs.stops {
if let Some(parent) = stop.parent_station.as_deref()
&& let Some(&child_idx) = stop_by_id.get(stop_id.as_str())
&& gtfs.stops.contains_key(parent)
{
station_children
.entry(parent.to_owned())
.or_default()
.push((child_idx, Duration::ZERO));
}
}
Ok(Self {
stop_ids,
route_ids,
trip_ids,
stop_by_id,
route_by_id,
routes_by_gtfs_id,
trip_by_id,
routes_for_stop,
stops_for_route,
trips_for_route,
arrival_times,
departure_times,
route_for_trip,
footpaths_for_stops,
transfer_times,
transfers_closed: false,
station_children,
no_pickup,
no_drop_off,
inaccessible_trips,
inaccessible_stops,
})
}
pub fn assert_footpaths_closed(mut self) -> Self {
self.transfers_closed = true;
self
}
pub fn station_stops(&self, parent_id: &str) -> &[(StopIdx, Duration)] {
self.station_children
.get(parent_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn with_walking_footpaths(
mut self,
gtfs: &Gtfs,
max_distance_m: f64,
walking_speed_m_per_s: f64,
) -> Self {
let mut sum_lat = 0.0;
let mut n_with_coords = 0usize;
for stop in gtfs.stops.values() {
if let (Some(lat), Some(_)) = (stop.latitude, stop.longitude) {
sum_lat += lat;
n_with_coords += 1;
}
}
if n_with_coords == 0 {
return self;
}
let mean_lat_rad = (sum_lat / n_with_coords as f64).to_radians();
let lon_scale = mean_lat_rad.cos() * EARTH_RADIUS_M;
let lat_scale = EARTH_RADIUS_M;
let mut projected: Vec<ProjectedStop> = Vec::with_capacity(n_with_coords);
for (stop_id, stop) in >fs.stops {
if let (Some(lat), Some(lon)) = (stop.latitude, stop.longitude)
&& let Some(&idx) = self.stop_by_id.get(stop_id.as_str())
{
let x = lon.to_radians() * lon_scale;
let y = lat.to_radians() * lat_scale;
projected.push(ProjectedStop { pos: [x, y], idx });
}
}
let tree = RTree::bulk_load(projected.clone());
let r2 = max_distance_m * max_distance_m;
for from in &projected {
for near in tree.locate_within_distance(from.pos, r2) {
if near.idx == from.idx {
continue;
}
if self.transfer_times.contains_key(&(from.idx, near.idx)) {
continue;
}
let dx = from.pos[0] - near.pos[0];
let dy = from.pos[1] - near.pos[1];
let dist_m = (dx * dx + dy * dy).sqrt();
let walk_time = Duration((dist_m / walking_speed_m_per_s).ceil() as u32);
self.footpaths_for_stops[from.idx.idx()].push(near.idx);
self.transfer_times.insert((from.idx, near.idx), walk_time);
}
}
self.transfers_closed = true;
self
}
pub fn n_trips(&self) -> usize {
self.trip_ids.len()
}
pub fn stop_id(&self, stop: StopIdx) -> &str {
&self.stop_ids[stop.idx()]
}
pub fn route_id(&self, route: RouteIdx) -> &str {
&self.route_ids[route.idx()]
}
pub fn trip_id(&self, trip: TripIdx) -> &str {
&self.trip_ids[trip.idx()]
}
pub fn stop_idx(&self, id: &str) -> Option<StopIdx> {
self.stop_by_id.get(id).copied()
}
pub fn route_idx(&self, id: &str) -> Option<RouteIdx> {
self.route_by_id.get(id).copied()
}
pub fn routes_for_gtfs_id(&self, id: &str) -> &[RouteIdx] {
self.routes_by_gtfs_id
.get(id)
.map(|sv| sv.as_slice())
.unwrap_or(&[])
}
pub fn trip_idx(&self, id: &str) -> Option<TripIdx> {
self.trip_by_id.get(id).copied()
}
pub fn shape_for_leg(
&self,
gtfs: &Gtfs,
trip_id: &str,
board_stop: StopIdx,
alight_stop: StopIdx,
) -> Option<Vec<(f32, f32)>> {
let trip = gtfs.trips.get(trip_id)?;
let shape_id = trip.shape_id.as_deref()?;
let shape = gtfs.shapes.get(shape_id)?;
if shape.is_empty() {
return None;
}
let mut shape_sorted: Vec<>fs_structures::Shape> = shape.iter().collect();
shape_sorted.sort_by_key(|s| s.sequence);
let board_id = self.stop_id(board_stop);
let alight_id = self.stop_id(alight_stop);
let board_st = trip.stop_times.iter().find(|st| st.stop.id == board_id)?;
let alight_st = trip.stop_times.iter().find(|st| st.stop.id == alight_id)?;
if let (Some(b_dist), Some(a_dist)) =
(board_st.shape_dist_traveled, alight_st.shape_dist_traveled)
{
return Some(slice_shape_by_dist(&shape_sorted, b_dist, a_dist));
}
let board_coord = (
board_st.stop.latitude? as f32,
board_st.stop.longitude? as f32,
);
let alight_coord = (
alight_st.stop.latitude? as f32,
alight_st.stop.longitude? as f32,
);
Some(slice_shape_by_projection(
&shape_sorted,
board_coord,
alight_coord,
))
}
}
fn split_non_overtaking<'gtfs>(
trips: &[(&'gtfs str, u8, &'gtfs [gtfs_structures::StopTime])],
) -> Vec<Vec<(&'gtfs str, u8)>> {
let mut sub_groups: Vec<Vec<(&'gtfs str, u8, &'gtfs [gtfs_structures::StopTime])>> = Vec::new();
'outer: for &entry in trips {
for sub_group in &mut sub_groups {
let (_, last_day, last_st) = *sub_group
.last()
.expect("sub_groups are seeded with vec![entry] and only ever grown");
if !overtakes(last_st, last_day, entry.2, entry.1) {
sub_group.push(entry);
continue 'outer;
}
}
sub_groups.push(vec![entry]);
}
sub_groups
.into_iter()
.map(|g| {
g.into_iter()
.map(|(id, day_offset, _)| (id, day_offset))
.collect()
})
.collect()
}
fn shift_dep(raw: u32, day_offset: u8) -> SecondOfDay {
SecondOfDay(raw.saturating_add(u32::from(day_offset) * 86_400))
}
fn overtakes(
earlier: &[gtfs_structures::StopTime],
earlier_day: u8,
later: &[gtfs_structures::StopTime],
later_day: u8,
) -> bool {
earlier.iter().zip(later).any(|(es, ls)| {
let e_dep = shift_dep(
es.departure_time.expect(
"stop_time.departure_time is required by the GtfsError::MissingDepartureTime check earlier in GtfsTimetable::new()",
),
earlier_day,
);
let l_dep = shift_dep(
ls.departure_time.expect(
"stop_time.departure_time is required by the GtfsError::MissingDepartureTime check earlier in GtfsTimetable::new()",
),
later_day,
);
if l_dep < e_dep {
return true;
}
matches!(
(es.arrival_time, ls.arrival_time),
(Some(e_arr), Some(l_arr)) if shift_dep(l_arr, later_day) < shift_dep(e_arr, earlier_day)
)
})
}
impl Timetable for GtfsTimetable {
fn n_stops(&self) -> usize {
self.stop_ids.len()
}
fn n_routes(&self) -> usize {
self.route_ids.len()
}
fn get_routes_serving_stop(&self, stop: StopIdx) -> &[(RouteIdx, u32)] {
self.routes_for_stop[stop.idx()].as_slice()
}
fn get_stops_after(&self, route: RouteIdx, pos: u32) -> &[StopIdx] {
let stops = &self.stops_for_route[route.idx()];
&stops[pos as usize..]
}
fn stop_at(&self, route: RouteIdx, pos: u32) -> StopIdx {
self.stops_for_route[route.idx()][pos as usize]
}
fn get_earliest_trip(&self, route: RouteIdx, at: SecondOfDay, pos: u32) -> Option<TripIdx> {
let trips = &self.trips_for_route[route.idx()];
let dep_row = &self.departure_times[route.idx()][pos as usize];
let idx = dep_row.partition_point(|&dep| dep < at);
if self.no_pickup.is_empty() {
return trips.get(idx).copied();
}
let mut idx = idx;
while let Some(&trip) = trips.get(idx) {
if !self.no_pickup.contains(&(trip, pos)) {
return Some(trip);
}
idx += 1;
}
None
}
fn earliest_accessible_trip(
&self,
route: RouteIdx,
at: SecondOfDay,
pos: u32,
require_wheelchair_accessible: bool,
) -> Option<TripIdx> {
if !require_wheelchair_accessible {
return self.get_earliest_trip(route, at, pos);
}
let trips = &self.trips_for_route[route.idx()];
let dep_row = &self.departure_times[route.idx()][pos as usize];
let mut idx = dep_row.partition_point(|&dep| dep < at);
let no_pickup_empty = self.no_pickup.is_empty();
while let Some(&trip) = trips.get(idx) {
let pickup_ok = no_pickup_empty || !self.no_pickup.contains(&(trip, pos));
if pickup_ok && !self.inaccessible_trips.contains(&trip) {
return Some(trip);
}
idx += 1;
}
None
}
fn get_arrival_time(&self, trip: TripIdx, pos: u32) -> SecondOfDay {
let (route_idx, trip_pos) = self.route_for_trip[trip.idx()];
self.arrival_times[route_idx.idx()][pos as usize][trip_pos]
}
fn get_departure_time(&self, trip: TripIdx, pos: u32) -> SecondOfDay {
let (route_idx, trip_pos) = self.route_for_trip[trip.idx()];
self.departure_times[route_idx.idx()][pos as usize][trip_pos]
}
fn get_footpaths_from(&self, stop: StopIdx) -> &[StopIdx] {
self.footpaths_for_stops[stop.idx()].as_slice()
}
fn get_transfer_time(&self, from: StopIdx, to: StopIdx) -> Duration {
self.transfer_times
.get(&(from, to))
.copied()
.unwrap_or(DEFAULT_TRANSFER_TIME)
}
fn pickup_allowed(&self, trip: TripIdx, pos: u32) -> bool {
self.no_pickup.is_empty() || !self.no_pickup.contains(&(trip, pos))
}
fn drop_off_allowed(&self, trip: TripIdx, pos: u32) -> bool {
self.no_drop_off.is_empty() || !self.no_drop_off.contains(&(trip, pos))
}
fn trip_wheelchair_accessible(&self, trip: TripIdx) -> bool {
self.inaccessible_trips.is_empty() || !self.inaccessible_trips.contains(&trip)
}
fn stop_wheelchair_accessible(&self, stop: StopIdx) -> bool {
self.inaccessible_stops.is_empty() || !self.inaccessible_stops.contains(&stop)
}
fn footpaths_are_transitively_closed(&self) -> bool {
self.transfers_closed
}
}
fn slice_shape_by_dist(
shape: &[>fs_structures::Shape],
board_dist: f32,
alight_dist: f32,
) -> Vec<(f32, f32)> {
let (lo, hi) = if board_dist <= alight_dist {
(board_dist, alight_dist)
} else {
return Vec::new();
};
shape
.iter()
.filter_map(|s| {
let d = s.dist_traveled?;
if d >= lo && d <= hi {
Some((s.latitude as f32, s.longitude as f32))
} else {
None
}
})
.collect()
}
fn slice_shape_by_projection(
shape: &[>fs_structures::Shape],
board: (f32, f32),
alight: (f32, f32),
) -> Vec<(f32, f32)> {
let nearest = |target: (f32, f32)| -> usize {
shape
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
let da = sq_dist((a.latitude as f32, a.longitude as f32), target);
let db = sq_dist((b.latitude as f32, b.longitude as f32), target);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0)
};
let bi = nearest(board);
let ai = nearest(alight);
let (lo, hi) = if bi <= ai {
(bi, ai)
} else {
return Vec::new();
};
shape[lo..=hi]
.iter()
.map(|s| (s.latitude as f32, s.longitude as f32))
.collect()
}
#[inline]
fn sq_dist(a: (f32, f32), b: (f32, f32)) -> f32 {
let dx = a.0 - b.0;
let dy = a.1 - b.1;
dx * dx + dy * dy
}
#[cfg(test)]
mod tests {
use super::*;
use gtfs_structures::{Calendar, CalendarDate, StopTime};
fn st(arr: u32, dep: u32) -> StopTime {
StopTime {
arrival_time: Some(arr),
departure_time: Some(dep),
..Default::default()
}
}
fn weekday_only_feed() -> Gtfs {
let mut g = Gtfs::default();
g.calendar.insert(
"weekday".into(),
Calendar {
id: "weekday".into(),
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false,
start_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(),
},
);
g
}
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn calendar_active_on_weekday_inside_window() {
let gtfs = weekday_only_feed();
assert!(is_service_active(>fs, "weekday", ymd(2026, 5, 4)));
}
#[test]
fn calendar_inactive_on_weekend_inside_window() {
let gtfs = weekday_only_feed();
assert!(!is_service_active(>fs, "weekday", ymd(2026, 5, 2)));
}
#[test]
fn calendar_inactive_outside_date_window() {
let gtfs = weekday_only_feed();
assert!(!is_service_active(>fs, "weekday", ymd(2025, 12, 31)));
assert!(!is_service_active(>fs, "weekday", ymd(2027, 1, 4)));
}
#[test]
fn calendar_dates_added_overrides_calendar_inactive() {
let mut gtfs = weekday_only_feed();
gtfs.calendar_dates.insert(
"weekday".into(),
vec![CalendarDate {
service_id: "weekday".into(),
date: ymd(2026, 5, 2),
exception_type: Exception::Added,
}],
);
assert!(is_service_active(>fs, "weekday", ymd(2026, 5, 2)));
}
#[test]
fn calendar_dates_deleted_overrides_calendar_active() {
let mut gtfs = weekday_only_feed();
gtfs.calendar_dates.insert(
"weekday".into(),
vec![CalendarDate {
service_id: "weekday".into(),
date: ymd(2026, 5, 4),
exception_type: Exception::Deleted,
}],
);
assert!(!is_service_active(>fs, "weekday", ymd(2026, 5, 4)));
}
#[test]
fn unknown_service_id_is_inactive() {
let gtfs = weekday_only_feed();
assert!(!is_service_active(
>fs,
"no-such-service",
ymd(2026, 5, 4)
));
}
#[test]
fn overtakes_detects_arrival_inversion() {
let earlier = vec![st(0, 0), st(20, 20)];
let later = vec![st(5, 5), st(15, 15)];
assert!(overtakes(&earlier, 0, &later, 0));
}
#[test]
fn overtakes_detects_departure_inversion() {
let earlier = vec![st(0, 0), st(10, 30)];
let later = vec![st(5, 5), st(10, 20)];
assert!(overtakes(&earlier, 0, &later, 0));
}
#[test]
fn non_overtaking_pair_is_clean() {
let earlier = vec![st(0, 0), st(10, 10)];
let later = vec![st(5, 5), st(15, 15)];
assert!(!overtakes(&earlier, 0, &later, 0));
}
#[test]
fn equal_schedules_do_not_overtake() {
let a = vec![st(0, 0), st(10, 10)];
let b = vec![st(0, 0), st(10, 10)];
assert!(!overtakes(&a, 0, &b, 0));
}
#[test]
fn day_offset_keeps_overlapping_schedules_non_overtaking() {
let day0 = vec![st(0, 0), st(10, 10)];
let day1 = vec![st(0, 0), st(10, 10)];
assert!(!overtakes(&day0, 0, &day1, 1));
assert!(overtakes(&day1, 1, &day0, 0));
}
#[test]
fn split_keeps_non_overtaking_trips_in_one_group() {
let t1 = vec![st(0, 0), st(10, 10)];
let t2 = vec![st(5, 5), st(15, 15)];
let t3 = vec![st(10, 10), st(20, 20)];
let trips = vec![
("t1", 0u8, t1.as_slice()),
("t2", 0u8, t2.as_slice()),
("t3", 0u8, t3.as_slice()),
];
let groups = split_non_overtaking(&trips);
assert_eq!(groups, vec![vec![("t1", 0), ("t2", 0), ("t3", 0)]]);
}
#[test]
fn split_separates_overtaking_trips() {
let t1_local = vec![st(0, 0), st(60, 60)];
let t2_express = vec![st(10, 10), st(20, 20)];
let t3_local = vec![st(70, 70), st(130, 130)];
let trips = vec![
("t1", 0u8, t1_local.as_slice()),
("t2", 0u8, t2_express.as_slice()),
("t3", 0u8, t3_local.as_slice()),
];
let groups = split_non_overtaking(&trips);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0], vec![("t1", 0), ("t3", 0)]);
assert_eq!(groups[1], vec![("t2", 0)]);
}
#[test]
fn gtfs_error_messages_carry_route_and_agency() {
let with_agency = GtfsError::MissingStopTimes {
trip: "t-42".into(),
route: "r-7".into(),
agency: Some("a-99".into()),
};
let msg = with_agency.to_string();
assert!(msg.contains("t-42"), "missing trip id in {msg}");
assert!(msg.contains("r-7"), "missing route id in {msg}");
assert!(msg.contains("a-99"), "missing agency id in {msg}");
let without_agency = GtfsError::MissingStop {
stop: "s-1".into(),
trip: "t-42".into(),
route: "r-7".into(),
agency: None,
};
let msg = without_agency.to_string();
assert!(msg.contains("s-1"));
assert!(msg.contains("t-42"));
assert!(msg.contains("r-7"));
assert!(
msg.contains("agency ?"),
"missing agency placeholder in {msg}"
);
}
fn synthetic_overnight_feed() -> Gtfs {
use gtfs_structures::{Route, Stop, Trip};
use std::sync::Arc;
let mut g = weekday_only_feed();
let stop_a = Arc::new(Stop {
id: "A".into(),
..Default::default()
});
let stop_b = Arc::new(Stop {
id: "B".into(),
..Default::default()
});
g.stops.insert("A".into(), Arc::clone(&stop_a));
g.stops.insert("B".into(), Arc::clone(&stop_b));
g.routes.insert(
"R".into(),
Route {
id: "R".into(),
..Default::default()
},
);
let trip = Trip {
id: "T".into(),
service_id: "weekday".into(),
route_id: "R".into(),
stop_times: vec![
StopTime {
arrival_time: Some(6 * 3600),
departure_time: Some(6 * 3600),
stop: Arc::clone(&stop_a),
..Default::default()
},
StopTime {
arrival_time: Some(6 * 3600 + 30 * 60),
departure_time: Some(6 * 3600 + 30 * 60),
stop: Arc::clone(&stop_b),
..Default::default()
},
],
..Default::default()
};
g.trips.insert("T".into(), trip);
g
}
#[test]
fn overnight_days_loads_next_day_trip() {
use crate::{Duration, RaptorCache, SecondOfDay, Timetable};
use jiff::civil::date;
let g = synthetic_overnight_feed();
let tt = GtfsTimetable::new(&g, date(2026, 5, 4)).unwrap();
let a = tt.stop_idx("A").unwrap();
let b = tt.stop_idx("B").unwrap();
let mut cache = RaptorCache::for_timetable(&tt);
let journeys = tt
.query()
.from(a)
.to(b)
.max_transfers(1)
.depart_at(SecondOfDay::hms(23, 0, 0))
.run_with_cache(&mut cache);
assert!(
journeys.iter().all(|j| j.plan.is_empty()),
"no journey expected without overnight load"
);
let tt =
GtfsTimetable::new_with_overnight_days(&g, date(2026, 5, 4), OvernightDays(1)).unwrap();
let a = tt.stop_idx("A").unwrap();
let b = tt.stop_idx("B").unwrap();
let mut cache = RaptorCache::for_timetable(&tt);
let journeys = tt
.query()
.from(&[(a, Duration::ZERO)])
.to(&[(b, Duration::ZERO)])
.max_transfers(1)
.depart_at(SecondOfDay::hms(23, 0, 0))
.run_with_cache(&mut cache);
let best = journeys
.iter()
.filter(|j| !j.plan.is_empty())
.min_by_key(|j| j.arrival())
.expect("overnight journey should exist");
assert_eq!(best.arrival(), SecondOfDay(86_400 + 6 * 3600 + 30 * 60));
}
#[test]
fn overnight_days_zero_matches_single_day_constructor() {
use crate::{RaptorCache, SecondOfDay, Timetable};
let g = synthetic_overnight_feed();
let tt_zero =
GtfsTimetable::new_with_overnight_days(&g, ymd_jiff(2026, 5, 4), OvernightDays(0))
.unwrap();
let a = tt_zero.stop_idx("A").unwrap();
let b = tt_zero.stop_idx("B").unwrap();
let mut cache = RaptorCache::for_timetable(&tt_zero);
let journeys = tt_zero
.query()
.from(a)
.to(b)
.max_transfers(1)
.depart_at(SecondOfDay::hms(5, 0, 0))
.run_with_cache(&mut cache);
let best = journeys
.iter()
.filter(|j| !j.plan.is_empty())
.min_by_key(|j| j.arrival())
.expect("base-day journey should exist");
assert_eq!(best.arrival(), SecondOfDay(6 * 3600 + 30 * 60));
}
fn ymd_jiff(y: i16, m: i8, d: i8) -> Date {
Date::new(y, m, d).unwrap()
}
#[test]
fn slice_shape_by_dist_inclusive_range() {
use gtfs_structures::Shape;
let s = |seq, lat, lon, d| Shape {
id: "x".into(),
latitude: lat,
longitude: lon,
sequence: seq,
dist_traveled: Some(d),
};
let shape_owned = [
s(0, 28.6, 77.1, 0.0),
s(1, 28.7, 77.2, 100.0),
s(2, 28.8, 77.3, 200.0),
s(3, 28.9, 77.4, 300.0),
];
let shape: Vec<&Shape> = shape_owned.iter().collect();
let out = slice_shape_by_dist(&shape, 100.0, 200.0);
assert_eq!(out, vec![(28.7, 77.2), (28.8, 77.3)]);
}
#[test]
fn slice_shape_by_dist_returns_empty_for_inverted_range() {
use gtfs_structures::Shape;
let s = |seq, d| Shape {
id: "x".into(),
latitude: 0.0,
longitude: 0.0,
sequence: seq,
dist_traveled: Some(d),
};
let shape_owned = [s(0, 0.0), s(1, 100.0)];
let shape: Vec<&Shape> = shape_owned.iter().collect();
assert_eq!(
slice_shape_by_dist(&shape, 100.0, 0.0),
Vec::<(f32, f32)>::new()
);
}
#[test]
fn slice_shape_by_projection_finds_closest_indices() {
use gtfs_structures::Shape;
let s = |seq, lat, lon| Shape {
id: "x".into(),
latitude: lat,
longitude: lon,
sequence: seq,
dist_traveled: None,
};
let shape_owned = [
s(0, 0.0, 0.0),
s(1, 1.0, 1.0),
s(2, 2.0, 2.0),
s(3, 3.0, 3.0),
];
let shape: Vec<&Shape> = shape_owned.iter().collect();
let out = slice_shape_by_projection(&shape, (0.9, 0.9), (2.1, 2.1));
assert_eq!(out, vec![(1.0, 1.0), (2.0, 2.0)]);
}
#[test]
fn shape_for_leg_returns_polyline_for_delhi_trip_with_shape() {
use gtfs_structures::Gtfs;
use jiff::civil::date;
let gtfs = Gtfs::new("../aux/dmrc_gtfs.zip").expect("Delhi GTFS loads");
let tt = GtfsTimetable::new(>fs, date(2024, 1, 15)).expect("timetable builds");
let (trip_id, trip) = gtfs
.trips
.iter()
.find(|(_, t)| t.shape_id.is_some() && t.stop_times.len() >= 2)
.expect("Delhi has trips with shapes");
let board_id = &trip.stop_times[0].stop.id;
let alight_id = &trip.stop_times.last().unwrap().stop.id;
let board_idx = tt.stop_idx(board_id).expect("board stop in catalogue");
let alight_idx = tt.stop_idx(alight_id).expect("alight stop in catalogue");
let shape = tt
.shape_for_leg(>fs, trip_id, board_idx, alight_idx)
.expect("shape extracted");
assert!(!shape.is_empty(), "shape should be non-empty");
for (lat, lon) in &shape {
assert!((28.0..30.0).contains(lat), "lat {lat} not in Delhi range");
assert!((76.0..78.0).contains(lon), "lon {lon} not in Delhi range");
}
}
#[test]
fn shape_for_leg_returns_none_for_unknown_trip() {
use gtfs_structures::Gtfs;
use jiff::civil::date;
let gtfs = Gtfs::new("../aux/dmrc_gtfs.zip").unwrap();
let tt = GtfsTimetable::new(>fs, date(2024, 1, 15)).unwrap();
let some_stop = tt.stop_idx("1").expect("Dilshad Garden");
assert!(
tt.shape_for_leg(>fs, "trip-that-does-not-exist", some_stop, some_stop)
.is_none()
);
}
#[test]
fn impossible_transfers_are_excluded_from_footpaths() {
use gtfs_structures::{Route, Stop, StopTransfer, TransferType, Trip};
use std::sync::Arc;
let mut g = weekday_only_feed();
let stop_a = Arc::new(Stop {
id: "A".into(),
transfers: vec![
StopTransfer {
to_stop_id: "B".into(),
transfer_type: TransferType::Recommended,
min_transfer_time: Some(60),
},
StopTransfer {
to_stop_id: "C".into(),
transfer_type: TransferType::Impossible,
min_transfer_time: None,
},
],
..Default::default()
});
let stop_b = Arc::new(Stop {
id: "B".into(),
..Default::default()
});
let stop_c = Arc::new(Stop {
id: "C".into(),
..Default::default()
});
g.stops.insert("A".into(), Arc::clone(&stop_a));
g.stops.insert("B".into(), Arc::clone(&stop_b));
g.stops.insert("C".into(), Arc::clone(&stop_c));
g.routes.insert(
"R".into(),
Route {
id: "R".into(),
..Default::default()
},
);
g.trips.insert(
"T".into(),
Trip {
id: "T".into(),
service_id: "weekday".into(),
route_id: "R".into(),
stop_times: vec![
StopTime {
arrival_time: Some(6 * 3600),
departure_time: Some(6 * 3600),
stop: Arc::clone(&stop_a),
..Default::default()
},
StopTime {
arrival_time: Some(6 * 3600 + 600),
departure_time: Some(6 * 3600 + 600),
stop: Arc::clone(&stop_b),
..Default::default()
},
],
..Default::default()
},
);
let tt = GtfsTimetable::new(&g, ymd_jiff(2026, 5, 4)).unwrap();
let a = tt.stop_idx("A").unwrap();
let b = tt.stop_idx("B").unwrap();
let c = tt.stop_idx("C").unwrap();
let footpaths = tt.get_footpaths_from(a);
assert!(
footpaths.contains(&b),
"Recommended transfer A->B must create a footpath, got {:?}",
footpaths,
);
assert!(
!footpaths.contains(&c),
"Impossible (transfer_type=3) A->C must not create a footpath, got {:?}",
footpaths,
);
}
#[test]
fn with_walking_footpaths_caps_per_leg_and_marks_closed() {
use gtfs_structures::{Route, Stop, Trip};
use std::sync::Arc;
let mut g = weekday_only_feed();
const STEP_DEG: f64 = 400.0 / 111_320.0;
let stop = |id: &str, lat: f64| {
Arc::new(Stop {
id: id.into(),
latitude: Some(lat),
longitude: Some(0.0),
..Default::default()
})
};
let stop_s = stop("S", 0.0);
let stop_m = stop("M", STEP_DEG);
let stop_t = stop("T", 2.0 * STEP_DEG);
g.stops.insert("S".into(), Arc::clone(&stop_s));
g.stops.insert("M".into(), Arc::clone(&stop_m));
g.stops.insert("T".into(), Arc::clone(&stop_t));
g.routes.insert(
"R".into(),
Route {
id: "R".into(),
..Default::default()
},
);
g.trips.insert(
"T1".into(),
Trip {
id: "T1".into(),
service_id: "weekday".into(),
route_id: "R".into(),
stop_times: vec![
StopTime {
arrival_time: Some(6 * 3600),
departure_time: Some(6 * 3600),
stop: Arc::clone(&stop_s),
..Default::default()
},
StopTime {
arrival_time: Some(6 * 3600 + 600),
departure_time: Some(6 * 3600 + 600),
stop: Arc::clone(&stop_m),
..Default::default()
},
StopTime {
arrival_time: Some(6 * 3600 + 1200),
departure_time: Some(6 * 3600 + 1200),
stop: Arc::clone(&stop_t),
..Default::default()
},
],
..Default::default()
},
);
let tt = GtfsTimetable::new(&g, ymd_jiff(2026, 5, 4))
.unwrap()
.with_walking_footpaths(&g, 500.0, 1.4);
assert!(
tt.footpaths_are_transitively_closed(),
"with_walking_footpaths must mark the relation closed so the \
runtime caps walking at one hop per round",
);
let s = tt.stop_idx("S").unwrap();
let m = tt.stop_idx("M").unwrap();
let t = tt.stop_idx("T").unwrap();
let from_s = tt.get_footpaths_from(s);
assert!(
from_s.contains(&m),
"S→M (400 m) must be a direct footpath, got {:?}",
from_s,
);
assert!(
!from_s.contains(&t),
"S→T (800 m) must not be a direct footpath at cap=500 m, got {:?}",
from_s,
);
let from_m = tt.get_footpaths_from(m);
assert!(
from_m.contains(&s) && from_m.contains(&t),
"M is within 400 m of both S and T, got {:?}",
from_m,
);
}
}