solverforge-fsr / src /api /route_geometry.rs
blackopsrepl's picture
feat(fsr): add snapshot-scoped route geometry
ae32abe
use axum::http::StatusCode;
use super::route_dto::{RouteGeometryStatus, RouteSegmentDto, TechnicianRouteGeometryDto};
use crate::data::{load_network, DemoDataError};
use crate::domain::{FieldServicePlan, TravelLeg};
pub(super) fn status_from_routing_error(error: solverforge_maps::RoutingError) -> StatusCode {
eprintln!("Bergamo route geometry failed: {error}");
match error {
solverforge_maps::RoutingError::InvalidCoordinate { .. } => StatusCode::BAD_REQUEST,
solverforge_maps::RoutingError::Cancelled => StatusCode::REQUEST_TIMEOUT,
solverforge_maps::RoutingError::Network(_)
| solverforge_maps::RoutingError::Parse(_)
| solverforge_maps::RoutingError::Io(_)
| solverforge_maps::RoutingError::SnapFailed { .. }
| solverforge_maps::RoutingError::NoPath { .. } => StatusCode::BAD_GATEWAY,
}
}
pub(super) async fn build_route_geometry(
plan: &FieldServicePlan,
) -> Result<Vec<TechnicianRouteGeometryDto>, solverforge_maps::RoutingError> {
let network = load_network().await.map_err(|error| match error {
DemoDataError::Routing(error) => error,
})?;
let mut routes = Vec::with_capacity(plan.technician_routes.len());
for route in &plan.technician_routes {
let mut segments = Vec::new();
let mut previous_location_idx = route.start_location_idx;
for &visit_idx in &route.visits {
let Some(visit) = plan.service_visits.get(visit_idx) else {
continue;
};
segments.push(build_route_segment(
plan,
&network,
&route.id,
previous_location_idx,
visit.location_idx,
)?);
previous_location_idx = visit.location_idx;
}
if !route.visits.is_empty() {
segments.push(build_route_segment(
plan,
&network,
&route.id,
previous_location_idx,
route.end_location_idx,
)?);
}
routes.push(TechnicianRouteGeometryDto {
route_id: route.id.clone(),
technician_id: route.technician_id.clone(),
technician_name: route.technician_name.clone(),
color: route.color.clone(),
segments,
});
}
Ok(routes)
}
fn build_route_segment(
plan: &FieldServicePlan,
network: &solverforge_maps::RoadNetwork,
route_id: &str,
from_location_idx: usize,
to_location_idx: usize,
) -> Result<RouteSegmentDto, solverforge_maps::RoutingError> {
let travel_leg = find_travel_leg(plan, from_location_idx, to_location_idx);
if !travel_leg.is_some_and(|leg| leg.reachable) {
return Ok(non_routed_segment(
route_id,
from_location_idx,
to_location_idx,
travel_leg,
RouteGeometryStatus::UnreachableLeg,
));
}
let from = plan.locations.get(from_location_idx).ok_or_else(|| {
solverforge_maps::RoutingError::Network("route source location missing".into())
})?;
let to = plan.locations.get(to_location_idx).ok_or_else(|| {
solverforge_maps::RoutingError::Network("route target location missing".into())
})?;
let route_result = network.route(
solverforge_maps::Coord::new(from.lat(), from.lng()),
solverforge_maps::Coord::new(to.lat(), to.lng()),
);
let route = match route_result {
Ok(route) => route.simplify(12.0),
Err(error) => {
if let Some(status) = recoverable_geometry_status(&error) {
return Ok(non_routed_segment(
route_id,
from_location_idx,
to_location_idx,
travel_leg,
status,
));
}
return Err(error);
}
};
Ok(RouteSegmentDto {
route_id: route_id.to_string(),
from_location_idx,
to_location_idx,
duration_seconds: route.duration_seconds,
distance_meters: route.distance_meters.round() as i64,
reachable: true,
geometry_status: RouteGeometryStatus::Routed,
encoded_polyline: solverforge_maps::encode_polyline(&route.geometry),
})
}
fn find_travel_leg(
plan: &FieldServicePlan,
from_location_idx: usize,
to_location_idx: usize,
) -> Option<&TravelLeg> {
let width = plan.locations.len();
plan.travel_legs
.get(from_location_idx.checked_mul(width)? + to_location_idx)
.filter(|leg| {
leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
})
.or_else(|| {
plan.travel_legs.iter().find(|leg| {
leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
})
})
}
fn non_routed_segment(
route_id: &str,
from_location_idx: usize,
to_location_idx: usize,
travel_leg: Option<&TravelLeg>,
geometry_status: RouteGeometryStatus,
) -> RouteSegmentDto {
RouteSegmentDto {
route_id: route_id.to_string(),
from_location_idx,
to_location_idx,
duration_seconds: travel_leg.map_or(0, |leg| leg.duration_seconds),
distance_meters: travel_leg.map_or(0, |leg| leg.distance_meters),
reachable: false,
geometry_status,
encoded_polyline: String::new(),
}
}
fn recoverable_geometry_status(
error: &solverforge_maps::RoutingError,
) -> Option<RouteGeometryStatus> {
match error {
solverforge_maps::RoutingError::SnapFailed { .. } => Some(RouteGeometryStatus::SnapFailed),
solverforge_maps::RoutingError::NoPath { .. } => Some(RouteGeometryStatus::NoPath),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{FieldServicePlan, Location, TravelLegInit};
#[test]
fn finds_dense_or_sparse_travel_leg() {
let plan = test_plan(vec![TravelLeg::new(TravelLegInit {
id: "leg-01-02".to_string(),
name: "leg-01-02".to_string(),
from_location_idx: 1,
to_location_idx: 2,
duration_seconds: 42,
distance_meters: 1000,
reachable: true,
})]);
let leg = find_travel_leg(&plan, 1, 2).expect("travel leg");
assert_eq!(leg.duration_seconds, 42);
}
#[test]
fn non_routed_segment_preserves_known_metrics() {
let plan = test_plan(vec![TravelLeg::new(TravelLegInit {
id: "leg-00-01".to_string(),
name: "leg-00-01".to_string(),
from_location_idx: 0,
to_location_idx: 1,
duration_seconds: 90,
distance_meters: 1200,
reachable: false,
})]);
let segment = non_routed_segment(
"route-00",
0,
1,
find_travel_leg(&plan, 0, 1),
RouteGeometryStatus::UnreachableLeg,
);
assert!(!segment.reachable);
assert_eq!(segment.geometry_status, RouteGeometryStatus::UnreachableLeg);
assert_eq!(segment.duration_seconds, 90);
assert!(segment.encoded_polyline.is_empty());
}
#[test]
fn only_snap_and_no_path_are_recoverable_segment_failures() {
let from = solverforge_maps::Coord::new(45.0, 9.0);
let to = solverforge_maps::Coord::new(46.0, 10.0);
assert_eq!(
recoverable_geometry_status(&solverforge_maps::RoutingError::NoPath { from, to }),
Some(RouteGeometryStatus::NoPath)
);
assert_eq!(
recoverable_geometry_status(&solverforge_maps::RoutingError::SnapFailed {
coord: from,
nearest_distance_m: None,
}),
Some(RouteGeometryStatus::SnapFailed)
);
assert_eq!(
recoverable_geometry_status(&solverforge_maps::RoutingError::Network("down".into())),
None
);
}
fn test_plan(travel_legs: Vec<TravelLeg>) -> FieldServicePlan {
FieldServicePlan::new(
vec![
Location::new(
"loc-0",
"loc-0",
"A".into(),
45_000_000,
9_000_000,
"x".into(),
),
Location::new(
"loc-1",
"loc-1",
"B".into(),
45_001_000,
9_001_000,
"x".into(),
),
Location::new(
"loc-2",
"loc-2",
"C".into(),
45_002_000,
9_002_000,
"x".into(),
),
],
Vec::new(),
travel_legs,
Vec::new(),
)
}
}