use std::collections::HashMap;
use geo::{ConvexHull, MultiPoint, Polygon};
use petgraph::algo::dijkstra;
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::EdgeRef;
use crate::graph::{node_to_latlon, SpatialGraph, XmlNode, XmlWay};
use crate::overpass::NetworkType;
use crate::reachability::EdgeInfo;
#[derive(Debug, Clone)]
pub struct FeasibleNode {
pub inbound_time: f64,
pub outbound_time: f64,
pub slack: f64,
}
#[derive(Debug, Clone)]
pub struct FeasibilityResult {
pub origin: NodeIndex,
pub destination: NodeIndex,
pub available_time: f64,
pub direct_time: f64,
pub feasible: HashMap<NodeIndex, FeasibleNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum InfeasibleReason {
BudgetTooTight {
direct_time: f64,
available_time: f64,
},
NoPathExists,
}
impl std::fmt::Display for InfeasibleReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InfeasibleReason::BudgetTooTight {
direct_time,
available_time,
} => write!(
f,
"budget too tight: direct travel time is {direct_time:.0} s \
but available time is only {available_time:.0} s \
(shortfall: {:.0} s)",
direct_time - available_time
),
InfeasibleReason::NoPathExists => {
write!(f, "no path exists between origin and destination")
}
}
}
}
impl std::error::Error for InfeasibleReason {}
#[derive(Clone)]
pub struct PrismGraph {
pub graph: SpatialGraph,
pub result: FeasibilityResult,
pub network_type: NetworkType,
}
pub fn compute_feasibility_with<F>(
graph: &DiGraph<XmlNode, XmlWay>,
origin: NodeIndex,
destination: NodeIndex,
available_time: f64,
mut cost: F,
) -> Result<FeasibilityResult, InfeasibleReason>
where
F: FnMut(EdgeInfo<'_>) -> f64,
{
let forward = dijkstra(graph, origin, None, |e| {
cost(EdgeInfo {
id: e.id(),
source: e.source(),
target: e.target(),
weight: e.weight(),
})
});
let direct_time = match forward.get(&destination) {
Some(&t) => t,
None => return Err(InfeasibleReason::NoPathExists),
};
if direct_time > available_time {
return Err(InfeasibleReason::BudgetTooTight {
direct_time,
available_time,
});
}
let reversed = petgraph::visit::Reversed(graph);
let backward = dijkstra(reversed, destination, None, |e| {
let id = e.id();
let (source, target) = graph.edge_endpoints(id).unwrap();
let weight = graph.edge_weight(id).unwrap();
cost(EdgeInfo {
id,
source,
target,
weight,
})
});
let mut feasible = HashMap::new();
for (&node, &inbound) in &forward {
if inbound > available_time {
continue;
}
if let Some(&outbound) = backward.get(&node) {
let total = inbound + outbound;
if total <= available_time {
feasible.insert(
node,
FeasibleNode {
inbound_time: inbound,
outbound_time: outbound,
slack: available_time - total,
},
);
}
}
}
Ok(FeasibilityResult {
origin,
destination,
available_time,
direct_time,
feasible,
})
}
pub fn compute_feasibility(
graph: &DiGraph<XmlNode, XmlWay>,
origin: NodeIndex,
destination: NodeIndex,
available_time: f64,
network_type: NetworkType,
) -> Result<FeasibilityResult, InfeasibleReason> {
compute_feasibility_with(graph, origin, destination, available_time, |e| {
e.weight.travel_time(network_type)
})
}
pub fn build_feasibility_polygon(
graph: &DiGraph<XmlNode, XmlWay>,
result: &FeasibilityResult,
min_slack: f64,
) -> Option<Polygon> {
let points: MultiPoint<f64> = result
.feasible
.iter()
.filter(|(_, n)| n.slack >= min_slack)
.map(|(&idx, _)| node_to_latlon(graph, idx))
.collect::<Vec<_>>()
.into();
if points.0.len() < 3 {
return None;
}
Some(points.convex_hull())
}
fn prism_subgraph(sg: &SpatialGraph, result: &FeasibilityResult) -> SpatialGraph {
let mut subgraph = DiGraph::new();
let mut old_to_new = HashMap::new();
for &old_idx in result.feasible.keys() {
let new_idx = subgraph.add_node(sg.graph[old_idx].clone());
old_to_new.insert(old_idx, new_idx);
}
for edge in sg.graph.edge_references() {
let (Some(&source), Some(&target)) = (
old_to_new.get(&edge.source()),
old_to_new.get(&edge.target()),
) else {
continue;
};
subgraph.add_edge(source, target, edge.weight().clone());
}
SpatialGraph::new(subgraph)
}
impl PrismGraph {
pub fn node_count(&self) -> usize {
self.result.feasible.len()
}
pub fn edge_count(&self) -> usize {
self.graph
.graph
.edge_references()
.filter(|edge| {
self.result.feasible.contains_key(&edge.source())
&& self.result.feasible.contains_key(&edge.target())
})
.count()
}
pub fn contains_node_id(&self, node_id: i64) -> bool {
self.result
.feasible
.keys()
.any(|&idx| self.graph.graph[idx].id == node_id)
}
pub fn slack_at_node_id(&self, node_id: i64) -> Option<f64> {
self.result
.feasible
.iter()
.find_map(|(&idx, node)| (self.graph.graph[idx].id == node_id).then_some(node.slack))
}
pub fn materialize(&self) -> SpatialGraph {
prism_subgraph(&self.graph, &self.result)
}
pub fn route(
&self,
origin_lat: f64,
origin_lon: f64,
dest_lat: f64,
dest_lon: f64,
max_snap_m: Option<f64>,
) -> Result<crate::routing::Route, crate::error::OsmGraphError> {
self.materialize().route(
origin_lat,
origin_lon,
dest_lat,
dest_lon,
self.network_type,
max_snap_m,
)
}
pub fn isochrones(
&self,
lat: f64,
lon: f64,
time_limits: Vec<f64>,
max_snap_m: Option<f64>,
) -> Option<Vec<geo::Polygon>> {
self.materialize()
.isochrones(lat, lon, time_limits, self.network_type, max_snap_m)
}
}
impl SpatialGraph {
#[allow(clippy::too_many_arguments)]
pub fn prism(
&self,
origin_lat: f64,
origin_lon: f64,
dest_lat: f64,
dest_lon: f64,
available_time: f64,
network_type: NetworkType,
max_snap_m: Option<f64>,
) -> Option<Result<PrismGraph, InfeasibleReason>> {
let origin = self.nearest_node_within(origin_lat, origin_lon, max_snap_m)?;
let destination = self.nearest_node_within(dest_lat, dest_lon, max_snap_m)?;
let result = compute_feasibility(
&self.graph,
origin,
destination,
available_time,
network_type,
);
Some(result.map(|result| PrismGraph {
graph: self.clone(),
result,
network_type,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{create_graph, XmlNode, XmlNodeRef, XmlTag, XmlWay};
use crate::overpass::NetworkType;
fn node(id: i64, lat: f64, lon: f64) -> XmlNode {
XmlNode {
id,
lat,
lon,
tags: vec![],
}
}
fn way(node_ids: Vec<i64>) -> XmlWay {
XmlWay {
id: 1,
nodes: node_ids
.into_iter()
.map(|id| XmlNodeRef { node_id: id })
.collect(),
tags: vec![XmlTag {
key: "highway".into(),
value: "residential".into(),
}],
length: 0.0,
speed_kph: 0.0,
walk_travel_time: 0.0,
bike_travel_time: 0.0,
drive_travel_time: 0.0,
geometry: Vec::new(),
}
}
fn linear_graph() -> DiGraph<XmlNode, XmlWay> {
let nodes = vec![
node(1, 0.000, 0.0),
node(2, 0.001, 0.0),
node(3, 0.002, 0.0),
node(4, 0.003, 0.0),
];
create_graph(
nodes,
vec![way(vec![1, 2, 3, 4])],
true,
false,
)
}
fn find_node(g: &DiGraph<XmlNode, XmlWay>, osm_id: i64) -> NodeIndex {
g.node_indices().find(|&i| g[i].id == osm_id).unwrap()
}
fn feasibility_ok(
g: &DiGraph<XmlNode, XmlWay>,
origin: NodeIndex,
dest: NodeIndex,
budget: f64,
) -> FeasibilityResult {
compute_feasibility(g, origin, dest, budget, NetworkType::Drive)
.expect("expected Ok but got Err")
}
#[test]
fn feasible_nodes_satisfy_budget() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let result = feasibility_ok(&g, origin, dest, 10_000.0);
for (_, n) in &result.feasible {
assert!(
n.inbound_time + n.outbound_time <= result.available_time + 1e-9,
"node violates budget: inbound={} outbound={} budget={}",
n.inbound_time,
n.outbound_time,
result.available_time
);
assert!(
n.slack >= -1e-9,
"slack must be non-negative, got {}",
n.slack
);
}
}
#[test]
fn origin_and_destination_are_feasible() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let result = feasibility_ok(&g, origin, dest, 10_000.0);
let o = result
.feasible
.get(&origin)
.expect("origin must be feasible");
assert_eq!(o.inbound_time, 0.0, "origin inbound should be 0");
let d = result
.feasible
.get(&dest)
.expect("destination must be feasible");
assert_eq!(d.outbound_time, 0.0, "destination outbound should be 0");
}
#[test]
fn prism_graph_view_exposes_induced_counts_and_slack_labels() {
let sg = SpatialGraph::new(linear_graph());
let prism = sg
.prism(0.0, 0.0, 0.003, 0.0, 10_000.0, NetworkType::Drive, None)
.expect("origin and destination should snap")
.expect("budget should be feasible");
assert_eq!(prism.node_count(), 4);
assert_eq!(prism.edge_count(), 6);
assert!(prism.contains_node_id(1));
assert!(prism.contains_node_id(4));
assert!(prism.slack_at_node_id(2).is_some());
assert_eq!(prism.graph.graph.node_count(), 4);
assert_eq!(
prism.materialize().graph.node_count(),
prism.result.feasible.len()
);
}
#[test]
fn slack_equals_budget_minus_travel_times() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let result = feasibility_ok(&g, origin, dest, 10_000.0);
for (_, n) in &result.feasible {
let expected = result.available_time - n.inbound_time - n.outbound_time;
assert!(
(n.slack - expected).abs() < 1e-9,
"slack mismatch: got {} expected {}",
n.slack,
expected
);
}
}
#[test]
fn direct_time_stored_in_result() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let result = feasibility_ok(&g, origin, dest, 10_000.0);
let dest_node = result.feasible.get(&dest).unwrap();
assert!(
(result.direct_time - dest_node.inbound_time).abs() < 1e-9,
"direct_time {} != destination inbound_time {}",
result.direct_time,
dest_node.inbound_time
);
}
#[test]
fn budget_too_tight_returns_err() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let direct_time = feasibility_ok(&g, origin, dest, 10_000.0).direct_time;
let err = compute_feasibility(&g, origin, dest, direct_time - 1.0, NetworkType::Drive)
.expect_err("expected BudgetTooTight");
match err {
InfeasibleReason::BudgetTooTight {
direct_time: dt,
available_time: at,
} => {
assert!(dt > at, "direct_time should exceed available_time");
assert!(
(dt - direct_time).abs() < 1e-9,
"reported direct_time {} doesn't match actual {}",
dt,
direct_time
);
}
other => panic!("expected BudgetTooTight, got {:?}", other),
}
}
#[test]
fn budget_too_tight_shortfall_is_correct() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let direct_time = feasibility_ok(&g, origin, dest, 10_000.0).direct_time;
let shortfall = 42.0;
let budget = direct_time - shortfall;
let err = compute_feasibility(&g, origin, dest, budget, NetworkType::Drive)
.expect_err("expected BudgetTooTight");
if let InfeasibleReason::BudgetTooTight {
direct_time: dt,
available_time: at,
} = err
{
assert!(
((dt - at) - shortfall).abs() < 1e-9,
"shortfall should be {shortfall} but got {}",
dt - at
);
}
}
#[test]
fn budget_exactly_equal_to_direct_time_is_ok() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let direct_time = feasibility_ok(&g, origin, dest, 10_000.0).direct_time;
let result = compute_feasibility(&g, origin, dest, direct_time, NetworkType::Drive)
.expect("budget == direct_time should be Ok");
assert!(result.feasible.contains_key(&dest));
assert!(result.feasible.contains_key(&origin));
}
#[test]
fn disconnected_graph_returns_no_path() {
let mut g: DiGraph<XmlNode, XmlWay> = DiGraph::new();
let a = g.add_node(node(1, 0.0, 0.0));
let b = g.add_node(node(2, 1.0, 1.0));
let err = compute_feasibility(&g, a, b, 10_000.0, NetworkType::Drive)
.expect_err("expected NoPathExists");
assert_eq!(err, InfeasibleReason::NoPathExists);
}
#[test]
fn budget_too_tight_display_mentions_shortfall() {
let err = InfeasibleReason::BudgetTooTight {
direct_time: 3600.0,
available_time: 1800.0,
};
let msg = err.to_string();
assert!(msg.contains("1800"), "should mention available_time: {msg}");
assert!(msg.contains("3600"), "should mention direct_time: {msg}");
assert!(
msg.contains("1800"),
"should mention shortfall (1800): {msg}"
);
}
#[test]
fn no_path_display_is_readable() {
let msg = InfeasibleReason::NoPathExists.to_string();
assert!(!msg.is_empty());
}
#[test]
fn closure_doubles_inbound_and_outbound_consistently() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let baseline = compute_feasibility(&g, origin, dest, 10_000.0, NetworkType::Drive)
.expect("baseline should be Ok");
let doubled = compute_feasibility_with(&g, origin, dest, 10_000.0, |e| {
e.weight.travel_time(NetworkType::Drive) * 2.0
})
.expect("doubled should be Ok");
for (node, base) in &baseline.feasible {
let d = doubled.feasible.get(node).expect("doubled missing a node");
assert!((d.inbound_time - 2.0 * base.inbound_time).abs() < 1e-9);
assert!((d.outbound_time - 2.0 * base.outbound_time).abs() < 1e-9);
assert!(
(d.inbound_time + d.outbound_time + d.slack - 10_000.0).abs() < 1e-9,
"doubled identity: in={} out={} slack={}",
d.inbound_time,
d.outbound_time,
d.slack
);
}
}
#[test]
fn closure_sees_original_orientation_in_both_searches() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let result = compute_feasibility_with(&g, origin, dest, 10_000.0, |e| {
if e.source.index() < e.target.index() {
10.0
} else {
100.0
}
})
.expect("should be Ok");
for (_, f) in &result.feasible {
assert!((f.inbound_time + f.outbound_time + f.slack - 10_000.0).abs() < 1e-9);
assert!(f.slack >= 0.0);
}
}
#[test]
fn convenience_wrapper_matches_with_variant() {
let g = linear_graph();
let origin = find_node(&g, 1);
let dest = find_node(&g, 4);
let a = compute_feasibility(&g, origin, dest, 10_000.0, NetworkType::Drive).unwrap();
let b = compute_feasibility_with(&g, origin, dest, 10_000.0, |e| {
e.weight.travel_time(NetworkType::Drive)
})
.unwrap();
assert_eq!(a.feasible.len(), b.feasible.len());
for (node, fa) in &a.feasible {
let fb = b.feasible.get(node).expect("node missing in _with result");
assert!((fa.inbound_time - fb.inbound_time).abs() < 1e-9);
assert!((fa.outbound_time - fb.outbound_time).abs() < 1e-9);
assert!((fa.slack - fb.slack).abs() < 1e-9);
}
}
}