diff --git a/rust/bambam-osm/src/app/mod.rs b/rust/bambam-osm/src/app/mod.rs index eac7915d..a61610bd 100644 --- a/rust/bambam-osm/src/app/mod.rs +++ b/rust/bambam-osm/src/app/mod.rs @@ -1 +1 @@ -pub mod wci; +pub mod network; diff --git a/rust/bambam-osm/src/app/network/common/cycleway_tag.rs b/rust/bambam-osm/src/app/network/common/cycleway_tag.rs new file mode 100644 index 00000000..a6b35ffe --- /dev/null +++ b/rust/bambam-osm/src/app/network/common/cycleway_tag.rs @@ -0,0 +1,38 @@ +/// The CyclewayTag is a type that characterizes the +/// level of safety of a cycleway. +/// +/// You can generate a new cycleway tag by passing in +/// the OSM way's cycleway attribute. +#[derive(Debug)] +pub enum CyclewayTag { + DedicatedNoBuffer, + NoDedicatedWithFacilities, + NoDedicatedNoFacilities, +} + +impl CyclewayTag { + pub fn new(tag: &str) -> Self { + if Self::is_dedicated_no_buffer(tag) { + CyclewayTag::DedicatedNoBuffer + } else if Self::is_no_dedicated_with_facilities(tag) { + CyclewayTag::NoDedicatedWithFacilities + } else { + CyclewayTag::NoDedicatedNoFacilities + } + } + //TODO: ADD DedicatedWithBuffer variant for LTS computations. + /// DedicatedNoBuffer variant is when a cycleway + /// is it's own dedicated space, but is still + /// a part of the road. + fn is_dedicated_no_buffer(tag: &str) -> bool { + matches!(tag, "lane" | "designated" | "track") + } + + /// NoDedicatedWithFacilities variant is when a + /// cycleway does not have a designated lane, but + /// has some facilities for cycling awareness such as + /// signage for sharing the road with vehicle traffic. + fn is_no_dedicated_with_facilities(tag: &str) -> bool { + matches!(tag, "crossing" | "shared" | "shared_lane") + } +} diff --git a/rust/bambam-osm/src/app/network/common/mod.rs b/rust/bambam-osm/src/app/network/common/mod.rs new file mode 100644 index 00000000..39a979a6 --- /dev/null +++ b/rust/bambam-osm/src/app/network/common/mod.rs @@ -0,0 +1,4 @@ +pub mod cycleway_tag; +pub mod ops; +pub mod way_rtree_entry; +pub const MIN_DISTANCE_RTREE_NEIGHBOR: f32 = 0.0001378; diff --git a/rust/bambam-osm/src/app/network/common/ops.rs b/rust/bambam-osm/src/app/network/common/ops.rs new file mode 100644 index 00000000..aebc17e5 --- /dev/null +++ b/rust/bambam-osm/src/app/network/common/ops.rs @@ -0,0 +1,58 @@ +use super::MIN_DISTANCE_RTREE_NEIGHBOR; +use crate::app::network::common::way_rtree_entry::WayRTreeEntry; +use crate::model::osm::graph::OsmNodeDataSerializable; +use crate::model::osm::graph::OsmWayDataSerializable; +use rstar::RTree; +use std::error::Error; + +/// Find the neighboring ways in the RTree from a given way centroid +pub fn find_neighboring_ways<'a>( + query_entry: &WayRTreeEntry, + rtree: &'a RTree, +) -> Vec<&'a WayRTreeEntry> { + rtree + .locate_within_distance( + [query_entry.centroid.x(), query_entry.centroid.y()], + MIN_DISTANCE_RTREE_NEIGHBOR, + ) + .filter(|entry_in_rtree| entry_in_rtree.way.osmid != query_entry.way.osmid) + .collect() +} + +/// Load ways from a CSV file and create R-tree entries for each way. +/// TODO: incorporate Overture's way attributes and move the logic for WayRTreeEntry up to bambam-core. +pub fn load_way_rtree_entries( + edges_file: &str, + nodes: &[OsmNodeDataSerializable], +) -> Result, Box> { + let mut edge_reader = csv::Reader::from_path(edges_file)?; + let mut way_entries = Vec::new(); + + for record in edge_reader.deserialize::() { + let way = match record { + Ok(way) => way, + Err(err) => { + eprintln!("Error reading row: {err}"); + continue; + } + }; + + if nodes.get(way.src_vertex_id.0).is_none() { + eprintln!( + "Warning: source vertex {} not found for way {}; skipping", + way.src_vertex_id.0, way.osmid + ); + continue; + } + + let osmid = way.osmid; + let Some(entry) = WayRTreeEntry::new(way) else { + eprintln!("Warning: could not create R-tree entry for way {osmid}"); + continue; + }; + + way_entries.push(entry); + } + + Ok(way_entries) +} diff --git a/rust/bambam-osm/src/app/network/common/way_rtree_entry.rs b/rust/bambam-osm/src/app/network/common/way_rtree_entry.rs new file mode 100644 index 00000000..f5da3ca2 --- /dev/null +++ b/rust/bambam-osm/src/app/network/common/way_rtree_entry.rs @@ -0,0 +1,59 @@ +use crate::model::osm::graph::OsmWayDataSerializable; +use geo::{BoundingRect, Centroid, Distance, Euclidean}; +use rstar::{PointDistance, RTreeObject, AABB}; + +/// `WayRTreeEntry` wraps `OsmWayDataSerializable` and caches the bounding box +/// and centroid of the way's `linestring`. It is used solely for efficient spatial queries +/// in an R-tree data structure. +/// +/// It is used in spatial queries for network analysis, such as computing +/// the Walking Comfort Index (WCI) or Level of Traffic Stress (LTS) for a way using +/// information from the way's geometry, attributes, and nearby ways. +/// +/// If we were to implement the `RTreeObject` trait directly on `OsmWayDataSerializable`, +/// we would have to compute the bounding box every time the `envelope()` method +/// is called, which is inefficient. +/// +/// This allows us to compute the bounding box and centroid once, and reuse them in O(1) +/// for multiple spatial queries. +#[derive(Clone)] +pub struct WayRTreeEntry { + bbox: AABB<[f32; 2]>, + pub centroid: geo::Point, + pub way: OsmWayDataSerializable, +} + +impl WayRTreeEntry { + pub fn new(way: OsmWayDataSerializable) -> Option { + // Grab the bounding rectangle of the linestring. If it doesn't exist, return None. + let rect = way.linestring.bounding_rect()?; + + // Compute the centroid of the linestring. If it doesn't exist, return None. + let centroid = way.linestring.centroid()?; + + // Create the bounding box from the linestring's bounding rectangle + Some(Self { + bbox: AABB::from_corners([rect.min().x, rect.min().y], [rect.max().x, rect.max().y]), + centroid, + way, + }) + } +} + +impl RTreeObject for WayRTreeEntry { + type Envelope = AABB<[f32; 2]>; // Envelope should be the same type as the bbox of WayRTreeEntry + fn envelope(&self) -> Self::Envelope { + self.bbox // return the cached bounding box + } +} + +impl PointDistance for WayRTreeEntry { + // NOTE: The PointDistance trait for WayRTreeEntry uses euclidean distance. + // We may want to consider using haversine distance since we are working with geographic coordinates. + // However, for small distances (in the case of local navigation), the difference may be negligible. + fn distance_2(&self, point: &[f32; 2]) -> f32 { + let query_point = geo::Point::new(point[0], point[1]); + let distance = Euclidean.distance(self.centroid, query_point); + distance * distance + } +} diff --git a/rust/bambam-osm/src/app/network/lts/mod.rs b/rust/bambam-osm/src/app/network/lts/mod.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/rust/bambam-osm/src/app/network/lts/mod.rs @@ -0,0 +1 @@ + diff --git a/rust/bambam-osm/src/app/network/mod.rs b/rust/bambam-osm/src/app/network/mod.rs new file mode 100644 index 00000000..9abee26a --- /dev/null +++ b/rust/bambam-osm/src/app/network/mod.rs @@ -0,0 +1,3 @@ +mod common; +pub mod lts; +pub mod wci; diff --git a/rust/bambam-osm/src/app/network/wci/bulk_compute_wci.rs b/rust/bambam-osm/src/app/network/wci/bulk_compute_wci.rs new file mode 100644 index 00000000..007aa0b0 --- /dev/null +++ b/rust/bambam-osm/src/app/network/wci/bulk_compute_wci.rs @@ -0,0 +1,97 @@ +use crate::app::network::wci::compute_wci::compute_wci; +use crate::app::network::wci::wci_score::WciError; +use crate::app::network::{ + common::ops::load_way_rtree_entries, wci::compute_wci::WciComponentScores, +}; +use crate::model::osm::graph::OsmNodeDataSerializable; +use kdam::{Bar, BarBuilder, BarExt}; +use rayon::prelude::*; +use routee_compass_core::util::fs::read_utils; +use rstar::RTree; +use std::sync::{Arc, Mutex}; +use std::{ + error::Error, + fs::File, + io::{BufWriter, Write}, +}; + +/// Bulk compute WCI scores for an OSM network by taking in a vertices-complete.csv +/// and edges-complete.csv. +/// +/// Reads the verticies and edges into memory, constructs an R-tree for spatial queries, +/// computes the WCI score for each way in parallel, and writes the scores to an output file. +/// +/// WCI scores are computed in parallel and written line-by-line to `output_file`. +/// Authors: EG (2025 original), AM (2026 refactor) +pub fn bulk_compute_wci( + edges_file: &str, + vertices_file: &str, + output_file: &str, +) -> Result<(), Box> { + println!("Reading:\n\t- vertex set @ {vertices_file}\n\t- edge set @ {edges_file}"); + // load all vertices into memory + let vertices: Box<[OsmNodeDataSerializable]> = + read_utils::from_csv(&vertices_file, true, None, None)?; + // load all ways into memory as type WayRTreeEntry for insertion into the R-tree + let way_rtree_entries = load_way_rtree_entries(edges_file, &vertices)?; + println!("Edges and vertices read successfully."); + // bulk-load a copy of the entries into the r-tree; we keep `entries` around + // so each centroid can be paired with its own way during the WCI calculation + let rtree = RTree::bulk_load(way_rtree_entries.clone()); + + let bar: Arc> = Arc::new(Mutex::new( + BarBuilder::default() + .desc(format!("Computing WCI scores for the road network")) + .total(way_rtree_entries.len()) + .build()?, + )); + + // calculate the WCI component scores for each way in parallel using the compute_wci function + let wci_vec_with_components: Vec = way_rtree_entries + .par_iter() + .map(|way_entry| -> Result { + // compute wci for this entry + let wci = compute_wci( + &rtree, + way_entry, + vertices + .get(way_entry.way.src_vertex_id.0) + .unwrap_or(&OsmNodeDataSerializable::default()), + ); + // update pbar + if let Ok(mut bar) = bar.clone().lock() { + let _ = bar.update(1); + } + // return wci result + wci + }) + .collect::, WciError>>()?; + + let file = File::create(output_file)?; + let mut writer = BufWriter::new(file); + // write header + writeln!(writer, "wci_total,wci_walk,wci_speed,wci_cycle,wci_signal")?; + // write values + for wci in &wci_vec_with_components { + writeln!( + writer, + "{},{},{},{},{}", + wci.total_score, + wci.walkability_score + .as_ref() + .map_or(String::new(), |v| v.to_string()), + wci.traffic_speed_score + .as_ref() + .map_or(String::new(), |v| v.to_string()), + wci.cycleway_score + .as_ref() + .map_or(String::new(), |v| v.to_string()), + wci.traffic_signal_score + .as_ref() + .map_or(String::new(), |v| v.to_string()), + )?; + } + writer.flush()?; + println!("WCI scores computed successfully.\nWCI scores file saved @ {output_file}."); + Ok(()) +} diff --git a/rust/bambam-osm/src/app/network/wci/compute_wci.rs b/rust/bambam-osm/src/app/network/wci/compute_wci.rs new file mode 100644 index 00000000..782019f8 --- /dev/null +++ b/rust/bambam-osm/src/app/network/wci/compute_wci.rs @@ -0,0 +1,296 @@ +use crate::{ + app::network::{ + common::{ops::find_neighboring_ways, way_rtree_entry::WayRTreeEntry}, + wci::{ + ops::*, + wci_score::{WciError, WciScore, MAX_WCI_SCORE, MIN_WCI_SCORE}, + }, + }, + model::osm::graph::OsmNodeDataSerializable, +}; +use rstar::RTree; + +/// The Walking Comfort Index (WCI) scores for a way, including total score +/// and all components that went into the score. +#[derive(Default)] +pub struct WciComponentScores { + pub total_score: WciScore, + pub walkability_score: Option, + pub traffic_speed_score: Option, + pub cycleway_score: Option, + pub traffic_signal_score: Option, +} + +impl WciComponentScores { + pub fn min_wci_score() -> Result { + Ok(Self { + total_score: WciScore::new(MIN_WCI_SCORE)?, + ..Default::default() + }) + } + + pub fn max_wci_score() -> Result { + Ok(Self { + total_score: WciScore::new(MAX_WCI_SCORE)?, + ..Default::default() + }) + } +} + +/// Computes the walking comfort index (WCI) score for a given way (as WayRTreeEntry), +/// the way's source node, and the R-tree of all ways in the network. +pub fn compute_wci( + rtree: &RTree, + entry: &WayRTreeEntry, + src_node: &OsmNodeDataSerializable, +) -> Result { + let way_is_walk_eligible = way_is_walk_eligible(rtree, entry); + + let neighboring_ways = find_neighboring_ways(entry, rtree); + + if !way_is_walk_eligible { + // Total WCI score = Min WCI score (unwalkable roadway) + WciComponentScores::min_wci_score() + } else if way_is_footway(&entry.way) + || (neighboring_ways.is_empty() && way_is_sidewalk(&entry.way)) + { + // Total WCI score = Max WCI score (footway or sidewalk with no adjacent ways) + WciComponentScores::max_wci_score() + } else { + let walkability_score = WciScore::walkability_score(&entry.way); + + let cycleway_score = WciScore::cycleway_score(entry, &neighboring_ways); + + let traffic_speed_score = WciScore::traffic_speed_score(entry, &neighboring_ways); + + let traffic_signal_score = WciScore::traffic_signal_score(src_node); + + // Total = Sum of WCI component scores + Ok(WciComponentScores { + total_score: &walkability_score + + &traffic_speed_score + + &cycleway_score + + &traffic_signal_score, + walkability_score: Some(walkability_score), + traffic_speed_score: Some(traffic_speed_score), + cycleway_score: Some(cycleway_score), + traffic_signal_score: Some(traffic_signal_score), + }) + } +} + +#[cfg(test)] +mod test { + use super::compute_wci; + use crate::{ + app::network::{ + common::way_rtree_entry::WayRTreeEntry, + wci::{ + compute_wci::WciComponentScores, wci_score::MAX_WCI_SCORE, + wci_score::MIN_WCI_SCORE, WciScore, + }, + }, + model::osm::graph::{OsmNodeDataSerializable, OsmWayDataSerializable}, + }; + use rstar::RTree; + use serde_json; + + /// Unwalkable highway gives the minimum WCI score + #[test] + fn test_min_wci() { + let way: OsmWayDataSerializable = serde_json::from_str( + r#"{ + "osmid": 42, + "src_vertex_id": 0, + "dst_vertex_id": 1, + "highway": "motorway", + "maxspeed": "65 mph", + "linestring": "LINESTRING (-105.170016 39.773648, -105.165381 39.774176)", + "length_meters": 400.0 + }"#, + ) + .unwrap(); + + let src_vertex: OsmNodeDataSerializable = serde_json::from_str( + r#"{ + "osmid": 0, + "x": -105.170016, + "y": 39.773648 + }"#, + ) + .unwrap(); + + let entry = WayRTreeEntry::new(way).unwrap(); + let rtree: RTree = RTree::new(); // just need this to pass into wci, not using it. + + let score: WciComponentScores = compute_wci(&rtree, &entry, &src_vertex).unwrap(); + assert_eq!(score.total_score, WciScore::new(MIN_WCI_SCORE).unwrap()); + } + + /// A footway gives the max WCI score. + #[test] + fn test_max_wci() { + let way: OsmWayDataSerializable = serde_json::from_str( + r#"{ + "osmid": 42, + "src_vertex_id": 0, + "dst_vertex_id": 1, + "highway": "footway", + "footway": "alley", + "maxspeed": "", + "linestring": "LINESTRING (-105.170016 39.773648, -105.165381 39.774176)", + "length_meters": 400.0 + }"#, + ) + .unwrap(); + + let src_vertex: OsmNodeDataSerializable = serde_json::from_str( + r#"{ + "osmid": 0, + "x": -105.170016, + "y": 39.773648 + }"#, + ) + .unwrap(); + + let entry = WayRTreeEntry::new(way).unwrap(); + let rtree: RTree = RTree::new(); // just need this to pass into wci, not using it. + + let score: WciComponentScores = compute_wci(&rtree, &entry, &src_vertex).unwrap(); + assert_eq!(score.total_score, WciScore::new(MAX_WCI_SCORE).unwrap()); + } + + // a residential roadway with speed limit 25mph, a shared-lane + // cycleway, and a stop sign at the source node should have a positive wci score + #[test] + fn test_positive_wci() { + let way: OsmWayDataSerializable = serde_json::from_str( + r#"{ + "osmid": 42, + "src_vertex_id": 0, + "dst_vertex_id": 1, + "highway": "residential", + "cycleway": "shared_lane", + "maxspeed": "25 mph", + "linestring": "LINESTRING (-105.170016 39.773648, -105.165381 39.774176)", + "length_meters": 400.0 + }"#, + ) + .unwrap(); + + let src_vertex: OsmNodeDataSerializable = serde_json::from_str( + r#"{ + "osmid": 0, + "x": -105.170016, + "y": 39.773648, + "highway": "stop" + }"#, + ) + .unwrap(); + + let entry = WayRTreeEntry::new(way).unwrap(); + let rtree: RTree = RTree::new(); // just need this to pass into wci, not using it. + + // compute wci for the residential highway with nearby sidewalk + let score: WciComponentScores = compute_wci(&rtree, &entry, &src_vertex).unwrap(); + assert_eq!(score.traffic_speed_score, Some(WciScore::new(2).unwrap())); + assert_eq!(score.traffic_signal_score, Some(WciScore::new(1).unwrap())); + assert_eq!(score.cycleway_score, Some(WciScore::new(0).unwrap())); + assert_eq!(score.walkability_score, Some(WciScore::new(-2).unwrap())); + assert!(score.total_score > WciScore::new(0).unwrap()); + } + + // A residential highway with speed limit 45 mph and a stop sign at the source node + // should have a negative WCI score + #[test] + fn test_negative_wci() { + let way: OsmWayDataSerializable = serde_json::from_str( + r#"{ + "osmid": 42, + "src_vertex_id": 0, + "dst_vertex_id": 1, + "highway": "residential", + "maxspeed": "45 mph", + "linestring": "LINESTRING (-105.170016 39.773648, -105.165381 39.774176)", + "length_meters": 400.0 + }"#, + ) + .unwrap(); + + let src_vertex: OsmNodeDataSerializable = serde_json::from_str( + r#"{ + "osmid": 0, + "x": -105.170016, + "y": 39.773648, + "highway": "stop" + }"#, + ) + .unwrap(); + + let entry = WayRTreeEntry::new(way).unwrap(); + let rtree: RTree = RTree::new(); // just need this to pass into wci, not using it. + + // compute wci + let score: WciComponentScores = compute_wci(&rtree, &entry, &src_vertex).unwrap(); + assert_eq!(score.traffic_speed_score, Some(WciScore::new(-1).unwrap())); + assert_eq!(score.traffic_signal_score, Some(WciScore::new(1).unwrap())); + assert_eq!(score.cycleway_score, Some(WciScore::new(-2).unwrap())); + assert_eq!(score.walkability_score, Some(WciScore::new(-2).unwrap())); + assert_eq!(score.total_score, WciScore::new(-4).unwrap()); + assert!(score.total_score < WciScore::new(0).unwrap()); + } + + /// A residential highway with a bad score get's its + /// score buffed by a neighboring road with cycleway and low speed limit + #[test] + fn test_neighbor_wci_contribution() { + const WAY_SCORE_NO_NEIGHBORS: i32 = -4; // from the previous test + let way: OsmWayDataSerializable = serde_json::from_str( + r#"{ + "osmid": 42, + "src_vertex_id": 0, + "dst_vertex_id": 1, + "highway": "residential", + "maxspeed": "45 mph", + "linestring": "LINESTRING (-105.170016 39.773648, -105.165381 39.774176)", + "length_meters": 400.0 + }"#, + ) + .unwrap(); + + // This neighbor has a cycleway, and a low speed limit, so it's + // weighted score should contribute positively to the query's score + let neighbor: OsmWayDataSerializable = serde_json::from_str( + r#"{ + "osmid": 43, + "src_vertex_id": 2, + "dst_vertex_id": 3, + "highway": "residential", + "maxspeed": "25 mph", + "cycleway": "lane", + "linestring": "LINESTRING (-105.168085 39.773772, -105.166755 39.773937)", + "length_meters": 100 + }"#, + ) + .unwrap(); + + let src_vertex: OsmNodeDataSerializable = serde_json::from_str( + r#"{ + "osmid": 0, + "x": -105.170016, + "y": 39.773648, + "highway": "stop" + }"#, + ) + .unwrap(); + + let entry = WayRTreeEntry::new(way).unwrap(); + let neighbor_entry = WayRTreeEntry::new(neighbor).unwrap(); + let mut rtree: RTree = RTree::new(); + + rtree.insert(entry.clone()); + rtree.insert(neighbor_entry); + let score = compute_wci(&rtree, &entry, &src_vertex).unwrap(); + assert!(score.total_score > WciScore::new(WAY_SCORE_NO_NEIGHBORS).unwrap()); + } +} diff --git a/rust/bambam-osm/src/app/network/wci/mod.rs b/rust/bambam-osm/src/app/network/wci/mod.rs new file mode 100644 index 00000000..eca015e2 --- /dev/null +++ b/rust/bambam-osm/src/app/network/wci/mod.rs @@ -0,0 +1,8 @@ +mod bulk_compute_wci; +mod compute_wci; +mod ops; +mod wci_score; +pub use bulk_compute_wci::bulk_compute_wci; +pub use compute_wci::compute_wci; +pub use wci_score::WciScore; +const NO_CYCLEWAY_FOUND_SCORE: i32 = -2; // If there is no cycleway found for a way, cycle component of WCI. diff --git a/rust/bambam-osm/src/app/network/wci/ops.rs b/rust/bambam-osm/src/app/network/wci/ops.rs new file mode 100644 index 00000000..858de711 --- /dev/null +++ b/rust/bambam-osm/src/app/network/wci/ops.rs @@ -0,0 +1,182 @@ +use super::NO_CYCLEWAY_FOUND_SCORE; +use crate::app::network::common::cycleway_tag::CyclewayTag::{ + self, DedicatedNoBuffer, NoDedicatedNoFacilities, NoDedicatedWithFacilities, +}; +use crate::app::network::common::way_rtree_entry::WayRTreeEntry; +use crate::app::network::common::MIN_DISTANCE_RTREE_NEIGHBOR; +use crate::model::feature::highway::Highway; +use crate::model::osm::graph::{OsmNodeDataSerializable, OsmWayDataSerializable}; +use geo::{Distance, Euclidean}; +use rstar::RTree; + +/// Determines if a way is walk-eligible based on sidewalk/footway attributes or highway type. +/// +/// Args: +/// - way: the OsmWayDataSerializable to check +fn is_walkable(way: &OsmWayDataSerializable) -> bool { + let is_sidewalk = way_is_sidewalk(way); + + let is_footway = way_is_footway(way); + + let is_walkable_highway = way_is_walkable_highway(way); + + is_sidewalk || is_footway || is_walkable_highway +} + +/// Determines if the way is walk-eligible based on it's OSM attributes. +/// If the way is not walk-eligible, checks if any neighboring ways within a distance of 15 meters are walk-eligible. +/// +/// Args: +/// - `rtree`: RTree of all ways in the network +/// - `entry`: The way of interest (as WayRTreeEntry) +pub fn way_is_walk_eligible(rtree: &RTree, entry: &WayRTreeEntry) -> bool { + is_walkable(&entry.way) // check the way itself + || rtree // check neighboring ways + .locate_within_distance([entry.centroid.x(), entry.centroid.y()], MIN_DISTANCE_RTREE_NEIGHBOR) + .any(|neighbor| way_is_sidewalk(&neighbor.way)) +} + +// Checks if the way is a sidewalk +pub fn way_is_sidewalk(way: &OsmWayDataSerializable) -> bool { + way.sidewalk + .as_ref() + .is_some_and(|s| s != "no" && s != "none") + || way.footway == Some("sidewalk".to_string()) +} + +/// Checks if the way is a footway +pub fn way_is_footway(way: &OsmWayDataSerializable) -> bool { + way.footway + .as_ref() + .is_some_and(|s| s != "no" && s != "none") +} + +/// A walkable highway is a normal roadway that is typically low traffic or +/// low speed. +pub fn way_is_walkable_highway(way: &OsmWayDataSerializable) -> bool { + matches!( + way.highway, + Highway::Residential + | Highway::Unclassified + | Highway::LivingStreet + | Highway::Service + | Highway::Pedestrian + | Highway::Trailhead + | Highway::Track + | Highway::Footway + | Highway::Bridleway + | Highway::Steps + | Highway::Corridor + | Highway::Path + | Highway::Elevator + ) +} + +/// returns true if the node has a stop sign +pub fn has_stop_sign(node: &OsmNodeDataSerializable) -> bool { + node.clone() + .highway + .as_ref() + .is_some_and(|highway| highway.contains("stop")) +} + +/// returns true if the node has a traffic light +pub fn has_traffic_signals(node: &OsmNodeDataSerializable) -> bool { + node.clone() + .highway + .as_ref() + .is_some_and(|highway| highway.contains("traffic_signals")) +} + +/// Converts a cycleway tag classification to a numerical score. +pub fn cycleway_score_from_tag(tag: &CyclewayTag) -> i32 { + match tag { + DedicatedNoBuffer => 2, + NoDedicatedWithFacilities => 0, + NoDedicatedNoFacilities => -2, + } +} + +/// Computes the cycleway score from neighboring ways +pub fn cycleway_score_from_neighbors( + entry: &WayRTreeEntry, + neighboring_ways: &[&WayRTreeEntry], +) -> i32 { + let mut total_distance: f32 = 0.0; + let mut scored: Vec<(i32, f32)> = Vec::new(); + + for neighbor in neighboring_ways { + let distance = Euclidean.distance(entry.centroid, neighbor.centroid); + total_distance += distance; + if let Some(tag) = neighbor.way.cycleway.as_ref() { + scored.push((cycleway_score_from_tag(&CyclewayTag::new(tag)), distance)); + } + } + + if scored.is_empty() || total_distance == 0.0 { + return NO_CYCLEWAY_FOUND_SCORE; + } + + let weighted: f32 = scored + .iter() + .map(|&(score, d)| score as f32 * (d / total_distance)) + .sum(); + + weighted as i32 +} + +/// Converts a speed in MPH to a numerical score. +pub fn traffic_speed_score_from_speed(speed_mph: i32) -> i32 { + if speed_mph <= 25 { + 2 + } else if speed_mph > 25 && speed_mph <= 30 { + 1 + } else if speed_mph > 30 && speed_mph <= 40 { + 0 + } else if speed_mph > 40 && speed_mph <= 45 { + -1 + } else { + -2 + } +} + +/// Converts from whichever OSM maxspeed unit to MPH +pub fn traffic_speed_from_maxspeed(entry: &WayRTreeEntry) -> Option { + const KMH_TO_MPH: f64 = 0.621371; + match entry.way.get_speed("maxspeed", true) { + Ok(Some(velocity)) => { + let speed_kmh = velocity.get::(); + Some((speed_kmh * KMH_TO_MPH) as f32) // speed in mph + } + _ => None, + } +} + +/// Computes a weighted traffic speed score from nearby ways if the +/// way of interest does not have a speed limit +pub fn traffic_speed_score_from_neighbors( + entry: &WayRTreeEntry, + neighboring_ways: &Vec<&WayRTreeEntry>, +) -> i32 { + let speeds_and_distances: Vec<(f32, f32)> = neighboring_ways + .iter() + .filter_map(|neighbor| { + traffic_speed_from_maxspeed(neighbor).map(|speed| { + let distance = Euclidean.distance(entry.centroid, neighbor.centroid); + (speed, distance) + }) + }) + .collect(); + + let sum_distances: f32 = speeds_and_distances + .iter() + .map(|(_, distance)| *distance) + .sum(); + + let weighted_speed: f32 = speeds_and_distances + .iter() + .map(|(speed, distance)| speed * distance / sum_distances) + .sum(); + + traffic_speed_score_from_speed(weighted_speed.round() as i32) +} diff --git a/rust/bambam-osm/src/app/network/wci/wci_score.rs b/rust/bambam-osm/src/app/network/wci/wci_score.rs new file mode 100644 index 00000000..f2f0a0a4 --- /dev/null +++ b/rust/bambam-osm/src/app/network/wci/wci_score.rs @@ -0,0 +1,117 @@ +use num_traits::CheckedAdd; + +use super::ops::*; +use crate::{ + app::network::common::{cycleway_tag::CyclewayTag, way_rtree_entry::WayRTreeEntry}, + model::osm::graph::{OsmNodeDataSerializable, OsmWayDataSerializable}, +}; +pub const MIN_WCI_SCORE: i32 = -6; +pub const MAX_WCI_SCORE: i32 = 9; + +#[derive(Default, Eq, PartialEq, PartialOrd, Debug)] +pub struct WciScore(i32); + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum WciError { + #[error("WciScore value '{0}' must be in the integer range: [-6..9]")] + ValueError(i32), +} + +// borrowed + borrowed -> owned +impl<'a> std::ops::Add<&'a WciScore> for &'a WciScore { + type Output = WciScore; + + fn add(self, rhs: Self) -> Self::Output { + let sum = self.0 + rhs.0; + WciScore::new(sum).unwrap_or_else(|_| WciScore(sum.clamp(MIN_WCI_SCORE, MAX_WCI_SCORE))) + } +} + +// owned + borrowed -> owned +impl std::ops::Add<&WciScore> for WciScore { + type Output = Self; + + fn add(self, rhs: &WciScore) -> Self::Output { + let sum = self.0 + rhs.0; + WciScore::new(sum).unwrap_or_else(|_| WciScore(sum.clamp(MIN_WCI_SCORE, MAX_WCI_SCORE))) + } +} + +// owned + owned -> owned +impl std::ops::Add for WciScore { + type Output = Self; + + fn add(self, rhs: WciScore) -> Self::Output { + let sum = self.0 + rhs.0; + WciScore::new(sum).unwrap_or_else(|_| WciScore(sum.clamp(MIN_WCI_SCORE, MAX_WCI_SCORE))) + } +} + +// checked: owned + owned -> owned +impl CheckedAdd for WciScore { + fn checked_add(&self, rhs: &Self) -> Option { + let sum = self.0 + rhs.0; + if (MIN_WCI_SCORE..=MAX_WCI_SCORE).contains(&sum) { + Some(WciScore(sum)) + } else { + None + } + } +} + +impl std::fmt::Display for WciScore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl WciScore { + pub fn new(value: i32) -> Result { + if (MIN_WCI_SCORE..=MAX_WCI_SCORE).contains(&value) { + Ok(WciScore(value)) + } else { + Err(WciError::ValueError(value)) + } + } + + /// Computes the walkability `WciScore` for a way. + pub fn walkability_score(way: &OsmWayDataSerializable) -> WciScore { + if way_is_sidewalk(way) || way_is_footway(way) { + WciScore(2) + } else { + WciScore(-2) + } + } + + /// Computes the traffic signal `WciScore` for a way. + pub fn traffic_signal_score(src_node: &OsmNodeDataSerializable) -> WciScore { + if has_traffic_signals(src_node) { + WciScore(2) + } else if has_stop_sign(src_node) { + WciScore(1) + } else { + WciScore(0) + } + } + + /// Computes the cycleway `WciScore` for a way. + pub fn cycleway_score( + entry: &WayRTreeEntry, + neighboring_ways: &Vec<&WayRTreeEntry>, + ) -> WciScore { + // if the way has a cycleway tag (string), use that, otherwise, use neighbors + match &entry.way.cycleway { + Some(tag) => WciScore(cycleway_score_from_tag(&CyclewayTag::new(tag))), + None => WciScore(cycleway_score_from_neighbors(entry, neighboring_ways)), + } + } + + /// Computes the traffic speed `WciScore` for a way + pub fn traffic_speed_score(entry: &WayRTreeEntry, neighbors: &Vec<&WayRTreeEntry>) -> WciScore { + WciScore( + traffic_speed_from_maxspeed(entry) + .map(|speed_mph| traffic_speed_score_from_speed(speed_mph.round() as i32)) + .unwrap_or_else(|| traffic_speed_score_from_neighbors(entry, neighbors)), + ) + } +} diff --git a/rust/bambam-osm/src/app/wci/mod.rs b/rust/bambam-osm/src/app/wci/mod.rs deleted file mode 100644 index 1a0f6ecf..00000000 --- a/rust/bambam-osm/src/app/wci/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod way_attributes_for_wci; -mod way_geometry_and_data; -mod wci_ops; - -pub use way_attributes_for_wci::WayAttributesForWCI; -pub use way_geometry_and_data::WayGeometryData; -pub use wci_ops::process_wci; - -pub const MAX_WCI_SCORE: i32 = 9; diff --git a/rust/bambam-osm/src/app/wci/way_attributes_for_wci.rs b/rust/bambam-osm/src/app/wci/way_attributes_for_wci.rs deleted file mode 100644 index bb7956db..00000000 --- a/rust/bambam-osm/src/app/wci/way_attributes_for_wci.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! WayAttributesForWCI struct is used to store information needed to calculate the Walking Comfort Index (wci.rs) -//! Information in the struct is derived from OSM data and neighbors in the RTree -//! August 2025 EG - -use super::WayGeometryData; -use crate::model::feature::highway::Highway; -use geo::prelude::*; -use rstar::RTree; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct WayAttributesForWCI { - pub speed_imp: Option, - pub sidewalk_exists: Option, - pub cycleway_exists: Option<(String, i32)>, - pub traffic_signals_exists: Option, - pub stops_exists: Option, - pub dedicated_foot: Option, - pub no_adjacent_roads: Option, - pub walk_eligible: Option, -} - -impl WayAttributesForWCI { - pub fn new( - centroid: geo::Point, - rtree: &RTree, - geo_data: &WayGeometryData, - ) -> Option { - let query_pointf32 = [centroid.x(), centroid.y()]; - let query_point = geo::Point::new(centroid.x(), centroid.y()); - - let mut sidewalk = match &geo_data.data.sidewalk { - Some(string) => !(string == "no" || string == "none"), - _ => false, - }; - - let foot = match &geo_data.data.footway { - Some(string) => !(string == "no" || string == "none"), - _ => false, - }; - - if geo_data.data.footway == Some("sidewalk".to_string()) { - sidewalk = true; - } - - let walk_el = walk_eligible(rtree, geo_data, query_pointf32, sidewalk, foot); - if !walk_el { - // return immediately - return None; - } - - let mut neighbors = vec![]; - for neighbor in rtree.locate_within_distance(query_pointf32, 0.0001378) { - neighbors.push(neighbor); - } - let no_adj = neighbors.is_empty(); - - let cycle = match &geo_data.data.cycleway { - Some(string) => { - if string == "lane" || string == "designated" || string == "track" { - ("dedicated", 2) - } else if string == "crossing" || string == "shared" || string == "shared_lane" { - ("some_cycleway", 0) - } else { - ("no_cycleway", -2) - } - } - _ => { - // neighbor weighting - // let weighted_cycle = 0; - let mut total_lengths: f32 = 0.0; - let mut cyclescores = vec![]; - for neighbor in rtree.locate_within_distance(query_pointf32, 0.0001378) { - let origin = neighbor.geo.centroid(); - if let Some(origin) = origin { - let int_length = Euclidean::distance(&geo::Euclidean, origin, query_point); - total_lengths += int_length; - if let Some(ref cycleway) = neighbor.data.cycleway { - let neighbor_cycle_score = if cycleway == "lane" - || cycleway == "designated" - || cycleway == "track" - { - 2 - } else if cycleway == "crossing" - || cycleway == "shared" - || cycleway == "shared_lane" - { - 0 - } else { - -2 - }; - cyclescores.push((neighbor_cycle_score, int_length)); - } - } else { - continue; - } - } - let mut result_cycle: f32 = 0.0; - for (neighbor_cyclescore, length) in &cyclescores { - let weight = length / total_lengths; - result_cycle += (*neighbor_cyclescore as f32) * weight; - } - if !cyclescores.is_empty() && total_lengths != 0.0 { - ("from_neighbors", result_cycle as i32) - } else { - ("no_cycleway", -2) - } - } - }; - - let speed: i32 = match geo_data.data.maxspeed.clone() { - Some(speed_str) => speed_str.parse::().unwrap_or_default(), - None => { - // look at neighbors, weighted average - let mut speeds = vec![]; - let mut total_lengths: f32 = 0.0; - for neighbor in rtree.locate_within_distance(query_pointf32, 0.0001378) { - if let Some(origin) = neighbor.geo.centroid() { - let int_length = Euclidean::distance(&geo::Euclidean, origin, query_point); - total_lengths += int_length; - if let Some(neighbor_speed_str) = &neighbor.data.maxspeed { - if let Ok(int_neighbor_speed) = neighbor_speed_str.parse::() { - speeds.push((int_neighbor_speed, int_length)); - } - } - } - } - let mut result_speed = 0.0; - for (neighbor_speed, length) in &speeds { - let weight = length / total_lengths; - result_speed += (*neighbor_speed as f32) * weight; - } - if !speeds.is_empty() && total_lengths != 0.0 { - result_speed as i32 - } else { - 0 - } - } - }; - - let way_info = WayAttributesForWCI { - speed_imp: Some(speed), - sidewalk_exists: Some(sidewalk), - cycleway_exists: Some((cycle.0.to_string(), cycle.1)), - traffic_signals_exists: Some(geo_data.traf_sig), - stops_exists: Some(geo_data.stop), - dedicated_foot: Some(foot), - no_adjacent_roads: Some(no_adj), - walk_eligible: Some(walk_el), - }; - - Some(way_info) - } - - /// Calculate the Walk Comfort Index (WCI) for a given way - pub fn wci_calculate(self) -> Option { - if self.walk_eligible == Some(false) { - None - } else if self.dedicated_foot == Some(true) - || (self.no_adjacent_roads == Some(true) && self.sidewalk_exists == Some(true)) - { - Some(super::MAX_WCI_SCORE) - } else { - // Speed: 0-25 mph: 2, 25-30 mph: 1, 30-40 mph: 0, 40-45 mph: -1, 45+ mph: -2 - fn speed_score(way: &WayAttributesForWCI) -> i32 { - match way.speed_imp { - Some(speed) => { - let mph = (speed as f64 / 1.61).round(); - if mph <= 25.0 { - 2 - } else if mph > 25.0 && mph <= 30.0 { - 1 - } else if mph > 30.0 && mph <= 40.0 { - 0 - } else if mph > 40.0 && mph <= 45.0 { - -1 - } else { - -2 - } - } - None => -2, - } - } - - // Sidewalk: +2 if present, -2 if not - fn sidewalk_score(way: &WayAttributesForWCI) -> i32 { - match way.sidewalk_exists { - Some(value) if value => 2, - _ => -2, - } - } - - // Cycleway: +2 if dedicated, 0 if some, -2 if none, or weighted from neihgbors - fn cycleway_score(way: &WayAttributesForWCI) -> i32 { - match way.cycleway_exists.as_ref() { - Some(cycle_score) => { - if cycle_score.0 == "dedicated" { - 2 - } else if cycle_score.0 == "some_cycleway" { - 0 - } else if cycle_score.0 == "from_neighbors" { - cycle_score.1 //check this works - } else { - -2 - } - } - None => -2, - } - } - - // Traffic Signals: +2 if traffic signals exists, 1 if stops exist, 0 if neither - fn signal_or_stop_score(way: &WayAttributesForWCI) -> i32 { - if way.traffic_signals_exists == Some(true) { - 2 - } else if way.stops_exists == Some(true) { - 1 - } else { - 0 - } - } - - // Final Score: Speed + Sidewalk + Signal + Stop + Cycle - let final_score = speed_score(&self) - + sidewalk_score(&self) - + cycleway_score(&self) - + signal_or_stop_score(&self); - Some(final_score) - } - } -} - -/// determines if the road is eligible for walking comfort index calculation -/// if one is true: has sidewalk, has footway, has correct highway type, or adjacent sidewalk -fn walk_eligible( - rtree: &RTree, - geo_data: &WayGeometryData, - query_pointf32: [f32; 2], - sidewalk: bool, - foot: bool, -) -> bool { - let this_highway: Highway = geo_data.data.highway.clone(); - let walk_highway_tag = matches!( - this_highway, - Highway::Residential - | Highway::Unclassified - | Highway::LivingStreet - | Highway::Service - | Highway::Pedestrian - | Highway::Trailhead - | Highway::Track - | Highway::Footway - | Highway::Bridleway - | Highway::Steps - | Highway::Corridor - | Highway::Path - | Highway::Elevator - ); - let is_walk_eligible = sidewalk || foot || walk_highway_tag; - - if is_walk_eligible { - return true; - } else { - // check for adjacent sidewalks - for neighbor in rtree.locate_within_distance(query_pointf32, 0.0001378) { - if let Some(ref sidewalk) = neighbor.data.sidewalk { - if sidewalk != "no" && sidewalk != "none" { - return true; - } - } // could also be neighboring footway=sidewalk - if neighbor.data.footway == Some("sidewalk".to_string()) { - return true; - } - } - } - false -} diff --git a/rust/bambam-osm/src/app/wci/way_geometry_and_data.rs b/rust/bambam-osm/src/app/wci/way_geometry_and_data.rs deleted file mode 100644 index e5498e58..00000000 --- a/rust/bambam-osm/src/app/wci/way_geometry_and_data.rs +++ /dev/null @@ -1,41 +0,0 @@ -// Way struct is used to store needed information for OSM data for utilization in WCI calculations (wci.rs) -// August 2025 EG - -use crate::model::osm::graph::OsmWayDataSerializable; -use geo::prelude::*; -use geo::{Euclidean, LineString}; -use rstar::{PointDistance, RTreeObject, AABB}; - -#[derive(Clone)] -pub struct WayGeometryData { - pub geo: LineString, - pub data: OsmWayDataSerializable, - pub stop: bool, - pub traf_sig: bool, -} - -impl RTreeObject for WayGeometryData { - type Envelope = AABB<[f32; 2]>; - fn envelope(&self) -> Self::Envelope { - match self.geo.bounding_rect() { - Some(bounding_box) => AABB::from_corners( - [bounding_box.min().x, bounding_box.min().y], - [bounding_box.max().x, bounding_box.max().y], - ), - None => AABB::from_corners([0.0, 0.0], [0.0, 0.0]), - } - } -} - -impl PointDistance for WayGeometryData { - fn distance_2(&self, point: &[f32; 2]) -> f32 { - let query_point = geo::Point::new(point[0], point[1]); - let midpoint = self.geo.centroid(); - if let Some(midpoint) = midpoint { - let distance = Euclidean::distance(&geo::Euclidean, &midpoint, &query_point); - distance * distance - } else { - f32::MAX - } - } -} diff --git a/rust/bambam-osm/src/app/wci/wci_ops.rs b/rust/bambam-osm/src/app/wci/wci_ops.rs deleted file mode 100644 index 235829a5..00000000 --- a/rust/bambam-osm/src/app/wci/wci_ops.rs +++ /dev/null @@ -1,110 +0,0 @@ -// Walk Comfort Index (WCI) Calculation in Rust -// Input: OSM data with attributes, Output: file with WCI scores for each way, one score per line -// Utilizes self-designed wayinfostruct and osminfostruct for data handling -// August 2025 EG - -use super::way_attributes_for_wci::WayAttributesForWCI; -use super::way_geometry_and_data::WayGeometryData; -use crate::model::osm::graph::OsmNodeDataSerializable; -use crate::model::osm::graph::OsmWayDataSerializable; -use csv; -use geo::algorithm::centroid::Centroid; -use kdam::Bar; -use kdam::BarBuilder; -use kdam::BarExt; -use rayon::prelude::*; -use routee_compass_core::util::fs::read_utils; -use rstar::RTree; -use std::sync::Arc; -use std::sync::Mutex; -use std::{ - error::Error, - fs::File, - io::{BufWriter, Write}, -}; - -// Process the WCI score by taking in a vertices-complete.csv and edges-complete.csv -// deserialize both of these, reading in information to construct an rtree -// process_wci stores the data in way_attributes_for_wci aand way_geometry_data structs -// wci_calculate calculates the WCI score for each way -// process_wci will print each score, line-by-line into an output .txt -pub fn process_wci( - edges_file: &str, - vertices_file: &str, - output_file: &str, -) -> Result<(), Box> { - let nodes_bar = BarBuilder::default().desc("WCI: read vertices file"); - let nodes: Box<[OsmNodeDataSerializable]> = - read_utils::from_csv(&vertices_file, true, Some(nodes_bar), None)?; - let mut edges_reader = csv::Reader::from_path(edges_file)?; - - let mut centroids = vec![]; - let mut rtree_data = vec![]; - - for row in edges_reader.deserialize() { - match row { - Ok(osm_data) => { - let r: OsmWayDataSerializable = osm_data; - let linestring = r.linestring.clone(); - let src_node = match nodes.get(r.src_vertex_id.0) { - Some(node) => node, - None => continue, // If source node is not found, skip this row - }; - let has_stop = src_node - .clone() - .highway - .as_ref() - .is_some_and(|h| h.contains("stop")); - let has_traf_sig = src_node - .clone() - .highway - .as_ref() - .is_some_and(|h| h.contains("traffic_signals")); - if let Some(centroid) = linestring.centroid() { - let centroid_geo: geo::Point = geo::Point::new(centroid.x(), centroid.y()); - centroids.push(centroid_geo); - let rtree_entry = WayGeometryData { - geo: linestring, - data: r, - stop: has_stop, - traf_sig: has_traf_sig, - }; - rtree_data.push(rtree_entry); - } - } - Err(err) => { - eprint!("Error reading row: {err}"); - } - } - } - - let rtree = RTree::bulk_load(rtree_data.clone()); - - let bar: Arc> = Arc::new(Mutex::new( - BarBuilder::default() - .desc("WCI") - .total(centroids.len()) - .build()?, - )); - let wci_vec: Vec = centroids - .into_par_iter() - .enumerate() - .filter_map(|(idx, centroid)| { - if let Ok(mut bar) = bar.clone().lock() { - let _ = bar.update(1); - } - WayAttributesForWCI::new(centroid, &rtree, &rtree_data[idx]) - .and_then(|w: WayAttributesForWCI| w.wci_calculate()) - }) - .collect(); - println!("wci_vec is {wci_vec:?}"); - - let file = File::create(output_file)?; - let mut writer = BufWriter::new(file); - - for wci in wci_vec { - writeln!(writer, "{wci:?}")?; - } - - Ok(()) -} diff --git a/rust/bambam-osm/src/model/osm/graph/osm_node_data_serializable.rs b/rust/bambam-osm/src/model/osm/graph/osm_node_data_serializable.rs index 07b199c5..6fff2c05 100644 --- a/rust/bambam-osm/src/model/osm/graph/osm_node_data_serializable.rs +++ b/rust/bambam-osm/src/model/osm/graph/osm_node_data_serializable.rs @@ -1,8 +1,7 @@ +use super::{OsmNodeData, OsmNodeId}; use itertools::Itertools; use serde::{Deserialize, Serialize}; -use super::{OsmNodeData, OsmNodeId}; - /// used for IO in flat (CSV) format #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct OsmNodeDataSerializable { diff --git a/rust/bambam/src/bin/bambam_util.rs b/rust/bambam/src/bin/bambam_util.rs index 4c17b542..a18cd07c 100644 --- a/rust/bambam/src/bin/bambam_util.rs +++ b/rust/bambam/src/bin/bambam_util.rs @@ -17,7 +17,7 @@ use bambam::model::input_plugin::grid::grid_input_plugin; use bambam::model::input_plugin::grid::grid_input_plugin_builder; use bambam::model::input_plugin::grid::grid_type::GridType; use bambam::model::input_plugin::population::population_source_config::PopulationSourceConfig; -use bambam_osm::app::wci; +use bambam_osm::app::network::wci; use bamcensus_acs::model::AcsType; use bamcensus_core::model::identifier::GeoidType; use h3o::Resolution; @@ -213,7 +213,7 @@ impl App { edges_osm, vertices_osm, } => { - if let Err(error) = wci::process_wci(edges_osm, vertices_osm, wci_file) { + if let Err(error) = wci::bulk_compute_wci(edges_osm, vertices_osm, wci_file) { eprintln!("error! {error:?}"); } Ok(()) diff --git a/script/map_wci.py b/script/map_wci.py new file mode 100644 index 00000000..9e9b7472 --- /dev/null +++ b/script/map_wci.py @@ -0,0 +1,95 @@ +"""Create interactive WCI maps from two edge CSV files.""" + +# Usage: +# 1. Run merge_wci.py to generate a CSV containing WCI scores and geometry. +# 2. Generate an interactive map: +# python wci_map.py --input merged_wci.csv --output wci_map.html +# 3. Open the resulting HTML file in a browser to explore WCI scores. +# +# The input CSV must contain a 'linestring' WKT geometry column and WCI fields +# such as wci_total, wci_walk, wci_speed, wci_cycle, and wci_signal. + +from __future__ import annotations + +import argparse +from pathlib import Path + +import geopandas as gpd +import pandas as pd + + +def load_wci_gdf(path: Path) -> gpd.GeoDataFrame: + """Load an OSM edges CSV containing WKT linestring geometry.""" + df = pd.read_csv(path) + + if "linestring" not in df.columns: + raise ValueError(f"{path} does not contain a 'linestring' column") + + return gpd.GeoDataFrame( + df, + geometry=gpd.GeoSeries.from_wkt(df["linestring"]), + crs="EPSG:4326", + ).drop(columns="linestring") + + +def create_wci_map( + gdf: gpd.GeoDataFrame, + output: Path, + title: str, +) -> None: + """Create and save an interactive WCI map.""" + m = gdf.explore( + column="wci_total", + cmap="viridis_r", + tiles="CartoDB positron", + legend=True, + tooltip=[ + "name", + "highway", + "wci_total", + "wci_walk", + "wci_speed", + "wci_cycle", + "wci_signal" + ], + ) + + m.save(output) + print(f"Saved {output}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate interactive WCI comparison maps." + ) + + parser.add_argument( + "--input", + type=Path, + required=True, + help="CSV containing WCI scores", + ) + + parser.add_argument( + "--output", + type=Path, + default=Path("wci_map.html"), + help="Output HTML for map", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + gdf = load_wci_gdf(args.input) + + create_wci_map( + gdf, + args.output, + "WCI", + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/script/merge_wci.py b/script/merge_wci.py new file mode 100644 index 00000000..91348d6d --- /dev/null +++ b/script/merge_wci.py @@ -0,0 +1,100 @@ +""" +Merges wci scores with edges-complete.csv file +""" + +# Usage: +# Merge WCI scores with an OSM edges-complete CSV before generating maps: +# +# python merge_wci.py \ +# --edges edge_complete.csv \ +# --wci wci_scores.csv \ +# --output merged_wci.csv +# +# The input files must have matching row order and row counts: +# - --edges: edge network CSV containing geometry and OSM edge attributes +# - --wci: WCI score CSV with columns such as total_score, walk_score, +# traffic_speed_score, cycle_score, and traffic_signal_score +# - --output: combined CSV used by the WCI mapping script + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +csv.field_size_limit(sys.maxsize) + + +def read_wci_rows(path: Path) -> tuple[list[str], list[list[str]]]: + """Read a CSV containing WCI scores. + + Expected format: + + total_score,walk_score,traffic_speed_score,cycle_score,traffic_signal_score + 2,-2,2,0,2 + ... + """ + with path.open("r", newline="", encoding="utf-8") as f: + reader = csv.reader(f) + header = next(reader) + rows = [row for row in reader] + + return header, rows + + +def merge_wci( + edges_csv: Path, + wci_csv: Path, + output_csv: Path, +) -> None: + with edges_csv.open("r", newline="", encoding="utf-8") as f: + edge_rows = list(csv.reader(f)) + + if not edge_rows: + raise ValueError(f"{edges_csv} is empty") + + edge_header = edge_rows[0] + edge_data = edge_rows[1:] + + wci_header, wci_rows = read_wci_rows(wci_csv) + + if len(edge_data) != len(wci_rows): + raise ValueError( + f"Row count mismatch:\n" + f"edges: {len(edge_data)}\n" + f"wci: {len(wci_rows)}" + ) + + with output_csv.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(edge_header + wci_header) + + for edge, wci in zip(edge_data, wci_rows): + writer.writerow(edge + wci) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + + parser.add_argument("--edges", type=Path, required=True) + parser.add_argument("--wci", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + merge_wci( + args.edges.resolve(), + args.wci.resolve(), + args.output.resolve(), + ) + + print(f"Wrote {args.output}") + + +if __name__ == "__main__": + main() \ No newline at end of file