Something went wrong. Try again.
Live STIB-MIVB departures on the Pebble Time 2. It covers the bus, tram, and metro network of Brussels
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508use crate::model::RouteInfo;use anyhow::{Context, Result};use geo::{ Distance, Euclidean, Geodesic, InterpolateLine, Length, LineLocatePoint, LineString, Point, Simplify,};use std::collections::{HashMap, HashSet};
pub type Polyline = Vec<(f32, f32)>;pub type ShapesByLine = HashMap<String, Vec<(String, Polyline)>>;pub type ShapeIndex = HashMap<(String, String), Polyline>;
pub fn parse(zip_bytes: &[u8], routes: &HashMap<Box<str>, RouteInfo>) -> Result<ShapesByLine> { let mut route_id_to_line: HashMap<String, String> = routes.values().map(|r| (r.route_id.clone(), r.line.clone())).collect();
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).context("gtfs zip")?;
if let Ok(file) = archive.by_name("routes.txt") { let mut reader = csv::Reader::from_reader(file); let headers = reader.headers()?.clone(); let col = |name: &str| headers.iter().position(|h| h == name); if let (Some(id_col), Some(short_col)) = (col("route_id"), col("route_short_name")) { for record in reader.records() { let record = record?; let short = &record[short_col]; if routes.contains_key(short) { route_id_to_line.insert(record[id_col].to_string(), short.to_string()); } } } }
let mut shape_ids_by_line: HashMap<String, HashSet<String>> = HashMap::new(); { let file = archive.by_name("trips.txt").context("trips.txt missing")?; let mut reader = csv::Reader::from_reader(file); let headers = reader.headers()?.clone(); let col = |name: &str| headers.iter().position(|h| h == name); let (Some(route_col), Some(shape_col)) = (col("route_id"), col("shape_id")) else { anyhow::bail!("trips.txt header mismatch"); }; for record in reader.records() { let record = record?; let route_id = &record[route_col]; let shape_id = &record[shape_col]; if shape_id.is_empty() { continue; } let Some(line) = route_id_to_line.get(route_id) else { continue; }; shape_ids_by_line.entry(line.clone()).or_default().insert(shape_id.to_string()); } }
let known_shape_ids: HashSet<&str> = shape_ids_by_line.values().flatten().map(|s| s.as_str()).collect();
let mut points_by_shape: HashMap<String, Vec<(i64, f32, f32)>> = HashMap::new(); { let file = archive.by_name("shapes.txt").context("shapes.txt missing")?; let mut reader = csv::Reader::from_reader(file); let headers = reader.headers()?.clone(); let col = |name: &str| headers.iter().position(|h| h == name); let (Some(id_col), Some(lat_col), Some(lon_col), Some(seq_col)) = ( col("shape_id"), col("shape_pt_lat"), col("shape_pt_lon"), col("shape_pt_sequence"), ) else { anyhow::bail!("shapes.txt header mismatch"); }; for record in reader.records() { let record = record?; let shape_id = &record[id_col]; if !known_shape_ids.contains(shape_id) { continue; } let (Ok(lat), Ok(lon), Ok(seq)) = ( record[lat_col].parse::<f32>(), record[lon_col].parse::<f32>(), record[seq_col].parse::<i64>(), ) else { continue; }; points_by_shape.entry(shape_id.to_string()).or_default().push((seq, lat, lon)); } } for points in points_by_shape.values_mut() { points.sort_by_key(|p| p.0); }
let mut result: ShapesByLine = HashMap::new(); for (line, shape_ids) in shape_ids_by_line { let mut shapes: Vec<(String, Polyline)> = shape_ids .into_iter() .filter_map(|sid| { let points = points_by_shape.get(&sid)?; Some((sid, points.iter().map(|(_, lat, lon)| (*lat, *lon)).collect())) }) .collect(); shapes.sort_by(|a, b| a.0.cmp(&b.0)); if !shapes.is_empty() { result.insert(line, shapes); } } Ok(result)}
fn line_string(points: &[(f32, f32)]) -> LineString<f64> { points .iter() .map(|&(lat, lon)| (f64::from(lon), f64::from(lat))) .collect()}
fn polyline(line: LineString<f64>) -> Polyline { line.0 .into_iter() .map(|point| (point.y as f32, point.x as f32)) .collect()}
pub fn simplify(points: &[(f32, f32)], max_points: usize) -> Polyline { if max_points < 2 || points.len() <= max_points { return points.to_vec(); } let line = line_string(points); let mut lower = 0.0; let mut upper = Euclidean.length(&line).max(f64::EPSILON); let mut best = line.simplify(upper); for _ in 0..32 { let epsilon = (lower + upper) / 2.0; let candidate = line.simplify(epsilon); if candidate.0.len() <= max_points { upper = epsilon; best = candidate; } else { lower = epsilon; } } let mut result = polyline(best); result[0] = points[0]; let last = result.len() - 1; result[last] = *points.last().unwrap(); result}
pub fn section( points: &[(f32, f32)], first: (f64, f64), last: (f64, f64),) -> Option<Polyline> { if points.len() < 2 { return None; } let line = line_string(points); let first = line.line_locate_point(&Point::new(first.1, first.0))?; let last = line.line_locate_point(&Point::new(last.1, last.0))?; let (start, end) = if first <= last { (first, last) } else { (last, first) }; if start == end { return Some(points.to_vec()); } let length = Euclidean.length(&line); if length == 0.0 { return Some(points.to_vec()); } let start_point = Euclidean.point_at_ratio_from_start(&line, start)?; let end_point = Euclidean.point_at_ratio_from_start(&line, end)?; let mut result = vec![(start_point.y() as f32, start_point.x() as f32)]; let mut traversed = 0.0; for (index, segment) in line.lines().enumerate() { traversed += Euclidean.length(&segment); let ratio = traversed / length; if ratio > start && ratio < end { result.push(points[index + 1]); } } let end_point = (end_point.y() as f32, end_point.x() as f32); if result.last() != Some(&end_point) { result.push(end_point); } if result.len() < 2 || result.first() == result.last() { Some(points.to_vec()) } else { Some(result) }}
pub fn for_terminus( candidates: &[(String, Polyline)], origin: Option<(f64, f64)>, terminus_lat: f64, terminus_lon: f64,) -> Option<&Polyline> { let score = |points: &Polyline| { let &(last_lat, last_lon) = points.last().unwrap(); let end = Geodesic.distance( Point::new(terminus_lon, terminus_lat), Point::new(f64::from(last_lon), f64::from(last_lat)), ); let start = origin.map_or(0.0, |(origin_lat, origin_lon)| { let (first_lat, first_lon) = points[0]; Geodesic.distance( Point::new(origin_lon, origin_lat), Point::new(f64::from(first_lon), f64::from(first_lat)), ) }); end + start }; candidates .iter() .filter(|(_, points)| points.len() >= 2) .min_by(|left, right| score(&left.1).partial_cmp(&score(&right.1)).unwrap()) .map(|(_, points)| points)}
#[cfg(test)]mod tests { use super::*; use crate::model::VehicleKind; use std::io::Write;
fn route(line: &str, route_id: &str) -> RouteInfo { RouteInfo { line: line.to_string(), route_id: route_id.to_string(), kind: VehicleKind::Bus, color: "000000".into(), text_color: "FFFFFF".into(), } }
fn gtfs_zip(trips: &str, shapes: &str) -> Vec<u8> { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); z.start_file::<_, ()>("trips.txt", zip::write::FileOptions::default()).unwrap(); z.write_all(trips.as_bytes()).unwrap(); z.start_file::<_, ()>("shapes.txt", zip::write::FileOptions::default()).unwrap(); z.write_all(shapes.as_bytes()).unwrap(); z.finish().unwrap(); } buf.into_inner() }
const TRIPS: &str = "route_id,service_id,trip_id,shape_id\n\R62,1,t1,S1\n\R62,1,t2,S1\n\R62,1,t3,S2\n\RX,1,t4,S9\n";
const SHAPES: &str = "shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence\n\S1,50.80,4.30,2\n\S1,50.81,4.31,1\n\S1,50.82,4.32,3\n\S2,50.90,4.40,1\n\S2,50.91,4.41,2\n\S9,51.00,4.50,1\n";
#[test] fn parse_groups_by_line_orders_by_sequence_and_filters_unknown_routes() { let mut routes = HashMap::new(); routes.insert("62".into(), route("62", "R62")); let zip = gtfs_zip(TRIPS, SHAPES); let by_line = parse(&zip, &routes).unwrap(); assert_eq!(by_line.len(), 1); let shapes = &by_line["62"]; assert_eq!(shapes.len(), 2); let s1 = shapes.iter().find(|(id, _)| id == "S1").unwrap(); assert_eq!( s1.1, vec![(50.81_f32, 4.31_f32), (50.80_f32, 4.30_f32), (50.82_f32, 4.32_f32)] ); let s2 = shapes.iter().find(|(id, _)| id == "S2").unwrap(); assert_eq!(s2.1, vec![(50.90_f32, 4.40_f32), (50.91_f32, 4.41_f32)]); }
#[test] fn parse_dedupes_repeated_shape_ids_within_a_route() { let mut routes = HashMap::new(); routes.insert("62".into(), route("62", "R62")); let zip = gtfs_zip(TRIPS, SHAPES); let by_line = parse(&zip, &routes).unwrap(); let shapes = &by_line["62"]; assert_eq!(shapes.iter().filter(|(id, _)| id == "S1").count(), 1); }
#[test] fn simplify_returns_all_points_within_budget() { let points = vec![(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)]; let out = simplify(&points, 5); assert_eq!(out, points); }
#[test] fn simplify_keeps_endpoints_and_respects_budget() { let points: Vec<(f32, f32)> = (0..20).map(|i| (i as f32, if i == 10 { 5.0 } else { 0.0 })).collect(); let out = simplify(&points, 4); assert!(out.len() <= 4); assert_eq!(out.first(), points.first()); assert_eq!(out.last(), points.last()); assert!(out.contains(&(10.0, 5.0))); }
#[test] fn for_terminus_picks_the_nearer_ending_shape() { let candidates = vec![ ("near".to_string(), vec![(50.0, 4.0), (50.1, 4.1)]), ("far".to_string(), vec![(50.0, 4.0), (52.0, 6.0)]), ]; let picked = for_terminus(&candidates, None, 50.1, 4.1).unwrap(); assert_eq!(picked, &vec![(50.0, 4.0), (50.1, 4.1)]); }
#[test] fn for_terminus_none_for_empty_candidates() { let candidates: Vec<(String, Vec<(f32, f32)>)> = Vec::new(); assert!(for_terminus(&candidates, None, 50.0, 4.0).is_none()); }
#[test] fn section_locates_route_span_and_keeps_both_ends() { let points = vec![ (50.0, 4.0), (50.1, 4.1), (50.2, 4.2), (50.3, 4.3), ]; let section = section(&points, (50.1, 4.1), (50.2, 4.2)).unwrap(); assert_eq!(section.len(), 2); assert!((section[0].0 - 50.1).abs() < 1e-5); assert!((section[0].1 - 4.1).abs() < 1e-5); assert!((section[1].0 - 50.2).abs() < 1e-5); assert!((section[1].1 - 4.2).abs() < 1e-5); }}
#[cfg(test)]mod repro { use super::*; use crate::model::VehicleKind; use std::io::Write;
fn route(line: &str, route_id: &str) -> RouteInfo { RouteInfo { line: line.to_string(), route_id: route_id.to_string(), kind: VehicleKind::Bus, color: "000000".into(), text_color: "FFFFFF".into(), } }
fn zip_with(files: &[(&str, &str)]) -> Vec<u8> { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); for (name, body) in files { z.start_file::<_, ()>(*name, zip::write::FileOptions::default()).unwrap(); z.write_all(body.as_bytes()).unwrap(); } z.finish().unwrap(); } buf.into_inner() }
fn interp(a: (f32, f32), b: (f32, f32), n: usize) -> Vec<(f32, f32)> { (0..n) .map(|i| { let t = i as f32 / (n - 1) as f32; (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) }) .collect() }
fn shapes_csv(entries: &[(&str, &[(f32, f32)])]) -> String { let mut s = String::from("shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence\n"); for (id, pts) in entries { for (i, (lat, lon)) in pts.iter().enumerate() { s.push_str(&format!("{id},{lat},{lon},{i}\n")); } } s }
fn api_slice(poly: &[(f32, f32)], first: (f64, f64), last: (f64, f64)) -> Vec<(f32, f32)> { section(poly, first, last) .map(|section| simplify(§ion, 24)) .unwrap_or_default() }
const ORIGIN_34: (f32, f32) = (50.8380, 4.3670); const SAINTE_ANNE: (f32, f32) = (50.8070, 4.4310);
#[test] fn short_variant_must_not_collapse_the_strip_slice_to_one_point() { let stops = interp(ORIGIN_34, SAINTE_ANNE, 10); let full: Vec<(f32, f32)> = { let mut p = interp(ORIGIN_34, SAINTE_ANNE, 37); let l = p.last_mut().unwrap(); l.0 += 0.0003; p }; let short: Vec<(f32, f32)> = interp(stops[7], (SAINTE_ANNE.0 + 0.00005, SAINTE_ANNE.1), 9); let mut routes = std::collections::HashMap::new(); routes.insert("34".into(), route("34", "R34")); let zip = zip_with(&[ ("trips.txt", "route_id,service_id,trip_id,shape_id\nR34,1,t1,FULL\nR34,1,t2,SHORT\n"), ("shapes.txt", &shapes_csv(&[("FULL", &full), ("SHORT", &short)])), ]); let by_line = parse(&zip, &routes).unwrap(); let candidates = &by_line["34"]; let origin = Some((ORIGIN_34.0 as f64, ORIGIN_34.1 as f64)); let picked = for_terminus(candidates, origin, SAINTE_ANNE.0 as f64, SAINTE_ANNE.1 as f64).unwrap(); let stored = simplify(picked, 48); let strip_first = (stops[0].0 as f64, stops[0].1 as f64); let strip_last = (stops[5].0 as f64, stops[5].1 as f64); let slice = api_slice(&stored, strip_first, strip_last); assert!(slice.len() >= 2, "line 34 class: slice collapsed to {:?}", slice); }
const ERASME: (f32, f32) = (50.8130, 4.2350); const HERRMANN: (f32, f32) = (50.9010, 4.4260);
#[test] fn depot_spur_must_not_win_either_metro_terminus() { let stations = interp(ERASME, HERRMANN, 8); let dir_a: Vec<(f32, f32)> = { let mut p = interp(HERRMANN, ERASME, 30); p.last_mut().unwrap().0 += 0.00035; p }; let dir_b: Vec<(f32, f32)> = { let mut p = interp(ERASME, HERRMANN, 30); p.last_mut().unwrap().0 += 0.00035; p }; let spur_a = vec![ (ERASME.0 - 0.0035, ERASME.1 - 0.0075), (ERASME.0 - 0.0020, ERASME.1 - 0.0040), (ERASME.0 + 0.00007, ERASME.1), ]; let spur_b = vec![ (HERRMANN.0 + 0.0035, HERRMANN.1 + 0.0075), (HERRMANN.0 + 0.0020, HERRMANN.1 + 0.0040), (HERRMANN.0 + 0.00007, HERRMANN.1), ]; let mut routes = std::collections::HashMap::new(); routes.insert("5".into(), route("5", "R5")); let zip = zip_with(&[ ("trips.txt", "route_id,service_id,trip_id,shape_id\nR5,1,t1,DIRA\nR5,1,t2,DIRB\nR5,1,t3,SPURA\nR5,1,t4,SPURB\n"), ("shapes.txt", &shapes_csv(&[("DIRA", &dir_a), ("DIRB", &dir_b), ("SPURA", &spur_a), ("SPURB", &spur_b)])), ]); let by_line = parse(&zip, &routes).unwrap(); let candidates = &by_line["5"]; for (terminus, origin, strip_hi) in [(ERASME, HERRMANN, 2usize), (HERRMANN, ERASME, 5usize)] { let o = Some((origin.0 as f64, origin.1 as f64)); let picked = for_terminus(candidates, o, terminus.0 as f64, terminus.1 as f64).unwrap(); let stored = simplify(picked, 48); let f = stations[strip_hi.saturating_sub(2)]; let l = stations[strip_hi]; let slice = api_slice(&stored, (f.0 as f64, f.1 as f64), (l.0 as f64, l.1 as f64)); assert!( slice.len() >= 2, "metro 5 class at terminus {terminus:?}: slice collapsed to {:?}", slice ); } }
#[test] fn line_with_duplicate_route_rows_still_gets_shapes() { let pts = interp((50.80, 4.30), (50.84, 4.36), 12); let mut routes = std::collections::HashMap::new(); routes.insert("81".into(), route("81", "R81_SEASONAL")); let zip = zip_with(&[ ( "routes.txt", "route_id,route_short_name,route_type\nR81,81,0\nR81_SEASONAL,81,0\n", ), ("trips.txt", "route_id,service_id,trip_id,shape_id\nR81,1,t1,SH81\n"), ("shapes.txt", &shapes_csv(&[("SH81", &pts)])), ]); let by_line = parse(&zip, &routes).unwrap(); assert!( by_line.contains_key("81"), "line 81 class: shapes lost to route_id dedup, got {:?}", by_line.keys().collect::<Vec<_>>() ); }
#[test] fn for_terminus_has_no_distance_bound_so_absence_is_not_a_sanity_rejection() { let candidates = vec![("far".to_string(), vec![(40.0_f32, 0.0_f32), (40.1, 0.1)])]; assert!(for_terminus(&candidates, None, 50.8, 4.4).is_some()); }}