| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | use axum::{ |
| | body::Body, |
| | extract::{Path, State}, |
| | http::{header, StatusCode}, |
| | response::{IntoResponse, Response}, |
| | routing::{delete, get, post, put}, |
| | Json, Router, |
| | }; |
| | use chrono::{NaiveDateTime, NaiveTime}; |
| | use serde::{Deserialize, Serialize}; |
| | use std::collections::HashMap; |
| | use std::sync::Arc; |
| | use tower_http::cors::{Any, CorsLayer}; |
| | use utoipa::{OpenApi, ToSchema}; |
| | use utoipa_swagger_ui::SwaggerUi; |
| | use uuid::Uuid; |
| |
|
| | use crate::demo_data::{available_datasets, generate_by_name}; |
| | use crate::domain::{Vehicle, VehicleRoutePlan, Visit}; |
| | use crate::geometry::{encode_routes, EncodedSegment}; |
| | use crate::solver::{SolverConfig, SolverService, SolverStatus}; |
| | use solverforge::prelude::HardSoftScore; |
| | use std::time::Duration; |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | const BASE_DATE: &str = "2025-01-05"; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | pub fn seconds_to_iso(seconds: i64) -> String { |
| | let hours = (seconds / 3600) % 24; |
| | let mins = (seconds % 3600) / 60; |
| | let secs = seconds % 60; |
| | format!("{}T{:02}:{:02}:{:02}", BASE_DATE, hours, mins, secs) |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | pub fn iso_to_seconds(iso: &str) -> i64 { |
| | if let Ok(dt) = NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S") { |
| | let midnight = NaiveDateTime::new(dt.date(), NaiveTime::from_hms_opt(0, 0, 0).unwrap()); |
| | (dt - midnight).num_seconds() |
| | } else { |
| | 0 |
| | } |
| | } |
| |
|
| | |
| | pub struct AppState { |
| | pub solver: SolverService, |
| | } |
| |
|
| | impl AppState { |
| | pub fn new() -> Self { |
| | Self { |
| | solver: SolverService::new(), |
| | } |
| | } |
| | } |
| |
|
| | impl Default for AppState { |
| | fn default() -> Self { |
| | Self::new() |
| | } |
| | } |
| |
|
| | |
| | pub fn create_router() -> Router { |
| | let state = Arc::new(AppState::new()); |
| |
|
| | let cors = CorsLayer::new() |
| | .allow_origin(Any) |
| | .allow_methods(Any) |
| | .allow_headers(Any); |
| |
|
| | Router::new() |
| | |
| | .route("/health", get(health)) |
| | .route("/info", get(info)) |
| | |
| | .route("/demo-data", get(list_demo_data)) |
| | .route("/demo-data/{name}", get(get_demo_data)) |
| | .route("/demo-data/{name}/stream", get(get_demo_data_stream)) |
| | |
| | .route("/route-plans", post(create_route_plan)) |
| | .route("/route-plans", get(list_route_plans)) |
| | .route("/route-plans/{id}", get(get_route_plan)) |
| | .route("/route-plans/{id}/status", get(get_route_plan_status)) |
| | .route("/route-plans/{id}", delete(stop_solving)) |
| | .route("/route-plans/{id}/geometry", get(get_route_geometry)) |
| | |
| | .route("/route-plans/analyze", put(analyze_route_plan)) |
| | .route("/route-plans/recommendation", post(recommend_assignment)) |
| | .route("/route-plans/recommendation/apply", post(apply_recommendation)) |
| | |
| | .merge(SwaggerUi::new("/q/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) |
| | .layer(cors) |
| | .with_state(state) |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | #[derive(Debug, Serialize, ToSchema)] |
| | pub struct HealthResponse { |
| | |
| | pub status: &'static str, |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/health", |
| | responses((status = 200, description = "Service is healthy", body = HealthResponse)) |
| | )] |
| | async fn health() -> Json<HealthResponse> { |
| | Json(HealthResponse { status: "UP" }) |
| | } |
| |
|
| | |
| | #[derive(Debug, Serialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct InfoResponse { |
| | |
| | pub name: &'static str, |
| | |
| | pub version: &'static str, |
| | |
| | pub solver_engine: &'static str, |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/info", |
| | responses((status = 200, description = "Application info", body = InfoResponse)) |
| | )] |
| | async fn info() -> Json<InfoResponse> { |
| | Json(InfoResponse { |
| | name: "Vehicle Routing", |
| | version: env!("CARGO_PKG_VERSION"), |
| | solver_engine: "SolverForge-RS", |
| | }) |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/demo-data", |
| | responses((status = 200, description = "List of demo dataset names", body = Vec<String>)) |
| | )] |
| | async fn list_demo_data() -> Json<Vec<&'static str>> { |
| | Json(available_datasets().to_vec()) |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/demo-data/{name}", |
| | params(("name" = String, Path, description = "Demo dataset name")), |
| | responses( |
| | (status = 200, description = "Demo data retrieved", body = RoutePlanDto), |
| | (status = 404, description = "Dataset not found") |
| | ) |
| | )] |
| | async fn get_demo_data(Path(name): Path<String>) -> Result<Json<RoutePlanDto>, StatusCode> { |
| | match generate_by_name(&name) { |
| | Some(plan) => Ok(Json(RoutePlanDto::from_plan(&plan, None))), |
| | None => Err(StatusCode::NOT_FOUND), |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async fn get_demo_data_stream(Path(name): Path<String>) -> impl IntoResponse { |
| | use crate::routing::{BoundingBox, RoadNetwork}; |
| |
|
| | |
| | let mut plan = match generate_by_name(&name) { |
| | Some(p) => p, |
| | None => { |
| | let error = r#"data: {"event":"error","message":"Demo data not found"}"#; |
| | return Response::builder() |
| | .status(StatusCode::OK) |
| | .header(header::CONTENT_TYPE, "text/event-stream") |
| | .header(header::CACHE_CONTROL, "no-cache") |
| | .body(Body::from(format!("{}\n\n", error))) |
| | .unwrap(); |
| | } |
| | }; |
| |
|
| | |
| | let bbox = BoundingBox::new( |
| | plan.south_west_corner[0], |
| | plan.south_west_corner[1], |
| | plan.north_east_corner[0], |
| | plan.north_east_corner[1], |
| | ) |
| | .expand(0.05); |
| |
|
| | |
| | let coords: Vec<(f64, f64)> = plan |
| | .locations |
| | .iter() |
| | .map(|l| (l.latitude, l.longitude)) |
| | .collect(); |
| | let n = coords.len(); |
| |
|
| | |
| | let stream = async_stream::stream! { |
| | |
| | yield Ok::<_, std::convert::Infallible>( |
| | format!("data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Loading road network...\",\"percent\":5,\"detail\":\"{} locations\"}}\n\n", n) |
| | ); |
| |
|
| | let network = match RoadNetwork::load_or_fetch(&bbox).await { |
| | Ok(net) => { |
| | yield Ok(format!( |
| | "data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Road network ready\",\"percent\":15,\"detail\":\"{} nodes, {} edges\"}}\n\n", |
| | net.node_count(), net.edge_count() |
| | )); |
| | net |
| | } |
| | Err(e) => { |
| | tracing::warn!("Road routing failed, using haversine: {}", e); |
| | plan.finalize(); |
| | yield Ok("data: {\"event\":\"progress\",\"phase\":\"fallback\",\"message\":\"Using straight-line distances\",\"percent\":95}\n\n".to_string()); |
| |
|
| | |
| | let dto = RoutePlanDto::from_plan(&plan, None); |
| | let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string()); |
| | yield Ok(format!( |
| | "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\ |
| | data: {{\"event\":\"complete\",\"solution\":{}}}\n\n", |
| | solution_json |
| | )); |
| | return; |
| | } |
| | }; |
| |
|
| | |
| | let (matrix_tx, mut matrix_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>(); |
| | let network_for_matrix = std::sync::Arc::clone(&network); |
| | let coords_for_matrix = coords.clone(); |
| |
|
| | let matrix_handle = tokio::task::spawn_blocking(move || { |
| | network_for_matrix.compute_matrix_with_progress(&coords_for_matrix, |row, total| { |
| | let _ = matrix_tx.send((row, total)); |
| | }) |
| | }); |
| |
|
| | |
| | while let Some((row, total)) = matrix_rx.recv().await { |
| | |
| | let pct = 15 + (row + 1) * 60 / total; |
| | yield Ok(format!( |
| | "data: {{\"event\":\"progress\",\"phase\":\"matrix\",\"message\":\"Computing routes\",\"percent\":{},\"detail\":\"{}/{} locations\"}}\n\n", |
| | pct, row + 1, total |
| | )); |
| | } |
| |
|
| | |
| | let matrix = match matrix_handle.await { |
| | Ok(m) => m, |
| | Err(e) => { |
| | tracing::error!("Matrix computation failed: {}", e); |
| | plan.finalize(); |
| | let dto = RoutePlanDto::from_plan(&plan, None); |
| | let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string()); |
| | yield Ok(format!( |
| | "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready (fallback)\",\"percent\":100}}\n\n\ |
| | data: {{\"event\":\"complete\",\"solution\":{}}}\n\n", |
| | solution_json |
| | )); |
| | return; |
| | } |
| | }; |
| | plan.travel_time_matrix = matrix; |
| |
|
| | |
| | let (geo_tx, mut geo_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>(); |
| | let network_for_geo = std::sync::Arc::clone(&network); |
| | let coords_for_geo = coords.clone(); |
| |
|
| | let geo_handle = tokio::task::spawn_blocking(move || { |
| | network_for_geo.compute_all_geometries_with_progress(&coords_for_geo, |row, total| { |
| | let _ = geo_tx.send((row, total)); |
| | }) |
| | }); |
| |
|
| | |
| | while let Some((row, total)) = geo_rx.recv().await { |
| | |
| | let pct = 75 + (row + 1) * 20 / total; |
| | yield Ok(format!( |
| | "data: {{\"event\":\"progress\",\"phase\":\"geometry\",\"message\":\"Generating routes\",\"percent\":{},\"detail\":\"{}/{} paths\"}}\n\n", |
| | pct, row + 1, total |
| | )); |
| | } |
| |
|
| | |
| | let geometries = match geo_handle.await { |
| | Ok(g) => g, |
| | Err(e) => { |
| | tracing::error!("Geometry computation failed: {}", e); |
| | std::collections::HashMap::new() |
| | } |
| | }; |
| | plan.route_geometries = geometries; |
| |
|
| | |
| | let dto = RoutePlanDto::from_plan(&plan, None); |
| | let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string()); |
| |
|
| | |
| | yield Ok(format!( |
| | "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\ |
| | data: {{\"event\":\"complete\",\"solution\":{}}}\n\n", |
| | solution_json |
| | )); |
| | }; |
| |
|
| | let body = Body::from_stream(stream); |
| |
|
| | Response::builder() |
| | .status(StatusCode::OK) |
| | .header(header::CONTENT_TYPE, "text/event-stream") |
| | .header(header::CACHE_CONTROL, "no-cache") |
| | .header(header::CONNECTION, "keep-alive") |
| | .body(body) |
| | .unwrap() |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct VisitDto { |
| | |
| | pub id: String, |
| | |
| | pub name: String, |
| | |
| | pub location: [f64; 2], |
| | |
| | pub demand: i32, |
| | |
| | pub min_start_time: String, |
| | |
| | pub max_end_time: String, |
| | |
| | pub service_duration: i32, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub vehicle: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub previous_visit: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub next_visit: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub arrival_time: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub start_service_time: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub departure_time: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub driving_time_seconds_from_previous_standstill: Option<i32>, |
| | } |
| |
|
| | |
| | |
| | |
| | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct VehicleDto { |
| | |
| | pub id: String, |
| | |
| | pub name: String, |
| | |
| | pub capacity: i32, |
| | |
| | pub home_location: [f64; 2], |
| | |
| | pub departure_time: String, |
| | |
| | pub visits: Vec<String>, |
| | |
| | pub total_demand: i32, |
| | |
| | pub total_driving_time_seconds: i32, |
| | |
| | pub arrival_time: String, |
| | } |
| |
|
| | |
| | |
| | |
| | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct TerminationConfigDto { |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub seconds_spent_limit: Option<u64>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub unimproved_seconds_spent_limit: Option<u64>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub step_count_limit: Option<u64>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub unimproved_step_count_limit: Option<u64>, |
| | } |
| |
|
| | |
| | |
| | |
| | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct RoutePlanDto { |
| | |
| | pub name: String, |
| | |
| | pub south_west_corner: [f64; 2], |
| | |
| | pub north_east_corner: [f64; 2], |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub start_date_time: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub end_date_time: Option<String>, |
| | |
| | pub total_driving_time_seconds: i32, |
| | |
| | pub vehicles: Vec<VehicleDto>, |
| | |
| | pub visits: Vec<VisitDto>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub score: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub solver_status: Option<String>, |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub termination: Option<TerminationConfigDto>, |
| | |
| | |
| | #[serde(skip_serializing_if = "Option::is_none")] |
| | pub travel_time_matrix: Option<Vec<Vec<i64>>>, |
| | } |
| |
|
| | impl RoutePlanDto { |
| | |
| | |
| | |
| | pub fn from_plan(plan: &VehicleRoutePlan, status: Option<SolverStatus>) -> Self { |
| | |
| | let mut visit_vehicle: HashMap<usize, (String, usize)> = HashMap::new(); |
| | for v in &plan.vehicles { |
| | for (pos, &visit_idx) in v.visits.iter().enumerate() { |
| | visit_vehicle.insert(visit_idx, (v.id.to_string(), pos)); |
| | } |
| | } |
| |
|
| | |
| | let visit_id = |idx: usize| -> String { format!("v{}", idx) }; |
| |
|
| | |
| | let mut visit_timings: HashMap<usize, (i64, i64, i64, i32)> = HashMap::new(); |
| | for v in &plan.vehicles { |
| | let timings = plan.calculate_route_times(v); |
| | let mut prev_loc = v.home_location.index; |
| |
|
| | for timing in timings.iter() { |
| | let driving_time = plan.travel_time(prev_loc, plan.visits[timing.visit_idx].location.index); |
| | let service_start = timing.arrival.max(plan.visits[timing.visit_idx].min_start_time); |
| | visit_timings.insert( |
| | timing.visit_idx, |
| | (timing.arrival, service_start, timing.departure, driving_time as i32), |
| | ); |
| | prev_loc = plan.visits[timing.visit_idx].location.index; |
| | } |
| | } |
| |
|
| | |
| | let visits: Vec<VisitDto> = plan |
| | .visits |
| | .iter() |
| | .filter_map(|visit| { |
| | let loc = plan.locations.get(visit.location.index)?; |
| | let (vehicle_id, vehicle_pos) = visit_vehicle.get(&visit.index).cloned().unzip(); |
| | let vehicle_for_visit = vehicle_id.as_ref().and_then(|vid| { |
| | plan.vehicles.iter().find(|v| v.id.to_string() == *vid) |
| | }); |
| |
|
| | |
| | let (prev_visit, next_visit) = if let (Some(v), Some(pos)) = (vehicle_for_visit, vehicle_pos) { |
| | let prev = if pos > 0 { Some(visit_id(v.visits[pos - 1])) } else { None }; |
| | let next = if pos + 1 < v.visits.len() { Some(visit_id(v.visits[pos + 1])) } else { None }; |
| | (prev, next) |
| | } else { |
| | (None, None) |
| | }; |
| |
|
| | let timing = visit_timings.get(&visit.index); |
| |
|
| | Some(VisitDto { |
| | id: visit_id(visit.index), |
| | name: visit.name.clone(), |
| | location: [loc.latitude, loc.longitude], |
| | demand: visit.demand, |
| | min_start_time: seconds_to_iso(visit.min_start_time), |
| | max_end_time: seconds_to_iso(visit.max_end_time), |
| | service_duration: visit.service_duration as i32, |
| | vehicle: vehicle_id, |
| | previous_visit: prev_visit, |
| | next_visit, |
| | arrival_time: timing.map(|t| seconds_to_iso(t.0)), |
| | start_service_time: timing.map(|t| seconds_to_iso(t.1)), |
| | departure_time: timing.map(|t| seconds_to_iso(t.2)), |
| | driving_time_seconds_from_previous_standstill: timing.map(|t| t.3), |
| | }) |
| | }) |
| | .collect(); |
| |
|
| | |
| | let vehicles: Vec<VehicleDto> = plan |
| | .vehicles |
| | .iter() |
| | .map(|v| { |
| | let home_loc = plan |
| | .locations |
| | .get(v.home_location.index) |
| | .map(|l| [l.latitude, l.longitude]) |
| | .unwrap_or([0.0, 0.0]); |
| |
|
| | let total_driving = plan.total_driving_time(v); |
| | let route_times = plan.calculate_route_times(v); |
| |
|
| | |
| | let arrival = if v.visits.is_empty() { |
| | v.departure_time |
| | } else if let Some(last_timing) = route_times.last() { |
| | let last_visit = &plan.visits[last_timing.visit_idx]; |
| | let return_travel = plan.travel_time(last_visit.location.index, v.home_location.index); |
| | last_timing.departure + return_travel |
| | } else { |
| | v.departure_time |
| | }; |
| |
|
| | |
| | let total_demand: i32 = v |
| | .visits |
| | .iter() |
| | .filter_map(|&idx| plan.visits.get(idx)) |
| | .map(|visit| visit.demand) |
| | .sum(); |
| |
|
| | VehicleDto { |
| | id: v.id.to_string(), |
| | name: v.name.clone(), |
| | capacity: v.capacity, |
| | home_location: home_loc, |
| | departure_time: seconds_to_iso(v.departure_time), |
| | visits: v.visits.iter().map(|&idx| visit_id(idx)).collect(), |
| | total_demand, |
| | total_driving_time_seconds: total_driving as i32, |
| | arrival_time: seconds_to_iso(arrival), |
| | } |
| | }) |
| | .collect(); |
| |
|
| | |
| | let start_dt = plan.vehicles.iter().map(|v| v.departure_time).min(); |
| | let end_dt = vehicles.iter().map(|v| iso_to_seconds(&v.arrival_time)).max(); |
| |
|
| | Self { |
| | name: plan.name.clone(), |
| | south_west_corner: plan.south_west_corner, |
| | north_east_corner: plan.north_east_corner, |
| | start_date_time: start_dt.map(seconds_to_iso), |
| | end_date_time: end_dt.map(seconds_to_iso), |
| | total_driving_time_seconds: plan.total_driving_time_all() as i32, |
| | vehicles, |
| | visits, |
| | score: plan.score.map(|s| format!("{}", s)), |
| | solver_status: status.map(|s| s.as_str().to_string()), |
| | termination: None, |
| | travel_time_matrix: if plan.travel_time_matrix.is_empty() { |
| | None |
| | } else { |
| | Some(plan.travel_time_matrix.clone()) |
| | }, |
| | } |
| | } |
| |
|
| | |
| | pub fn to_domain(&self) -> VehicleRoutePlan { |
| | use crate::domain::Location; |
| |
|
| | |
| | let mut locations = Vec::new(); |
| | let mut depot_indices: HashMap<(i64, i64), usize> = HashMap::new(); |
| |
|
| | |
| | for vdto in &self.vehicles { |
| | let key = ( |
| | (vdto.home_location[0] * 1e6) as i64, |
| | (vdto.home_location[1] * 1e6) as i64, |
| | ); |
| | depot_indices.entry(key).or_insert_with(|| { |
| | let idx = locations.len(); |
| | locations.push(Location::new(idx, vdto.home_location[0], vdto.home_location[1])); |
| | idx |
| | }); |
| | } |
| |
|
| | |
| | let visit_id_to_idx: HashMap<&str, usize> = self |
| | .visits |
| | .iter() |
| | .enumerate() |
| | .map(|(i, v)| (v.id.as_str(), i)) |
| | .collect(); |
| |
|
| | |
| | let visit_start_idx = locations.len(); |
| | for (i, vdto) in self.visits.iter().enumerate() { |
| | locations.push(Location::new( |
| | visit_start_idx + i, |
| | vdto.location[0], |
| | vdto.location[1], |
| | )); |
| | } |
| |
|
| | |
| | let visits: Vec<Visit> = self |
| | .visits |
| | .iter() |
| | .enumerate() |
| | .map(|(i, vdto)| { |
| | let loc = locations[visit_start_idx + i].clone(); |
| | Visit::new(i, &vdto.name, loc) |
| | .with_demand(vdto.demand) |
| | .with_time_window( |
| | iso_to_seconds(&vdto.min_start_time), |
| | iso_to_seconds(&vdto.max_end_time), |
| | ) |
| | .with_service_duration(vdto.service_duration as i64) |
| | }) |
| | .collect(); |
| |
|
| | |
| | let vehicles: Vec<Vehicle> = self |
| | .vehicles |
| | .iter() |
| | .enumerate() |
| | .map(|(i, vdto)| { |
| | let key = ( |
| | (vdto.home_location[0] * 1e6) as i64, |
| | (vdto.home_location[1] * 1e6) as i64, |
| | ); |
| | let home_idx = depot_indices[&key]; |
| | let home_loc = locations[home_idx].clone(); |
| |
|
| | |
| | let visit_indices: Vec<usize> = vdto |
| | .visits |
| | .iter() |
| | .filter_map(|vid| visit_id_to_idx.get(vid.as_str()).copied()) |
| | .collect(); |
| |
|
| | let mut v = Vehicle::new(i, &vdto.name, vdto.capacity, home_loc); |
| | v.departure_time = iso_to_seconds(&vdto.departure_time); |
| | v.visits = visit_indices; |
| | v |
| | }) |
| | .collect(); |
| |
|
| | let mut plan = VehicleRoutePlan::new(&self.name, locations, visits, vehicles); |
| | plan.south_west_corner = self.south_west_corner; |
| | plan.north_east_corner = self.north_east_corner; |
| |
|
| | |
| | if let Some(matrix) = &self.travel_time_matrix { |
| | plan.travel_time_matrix = matrix.clone(); |
| | } else { |
| | plan.finalize(); |
| | } |
| | plan |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | #[utoipa::path( |
| | post, |
| | path = "/route-plans", |
| | request_body = RoutePlanDto, |
| | responses((status = 200, description = "Job ID", body = String)) |
| | )] |
| | async fn create_route_plan( |
| | State(state): State<Arc<AppState>>, |
| | Json(dto): Json<RoutePlanDto>, |
| | ) -> Result<String, StatusCode> { |
| | let id = Uuid::new_v4().to_string(); |
| | let mut plan = dto.to_domain(); |
| |
|
| | |
| | if let Err(e) = plan.init_routing().await { |
| | tracing::error!("Road routing initialization failed: {}", e); |
| | return Err(StatusCode::SERVICE_UNAVAILABLE); |
| | } |
| |
|
| | |
| | let config = if let Some(term) = &dto.termination { |
| | SolverConfig { |
| | time_limit: term.seconds_spent_limit.map(Duration::from_secs), |
| | unimproved_time_limit: term.unimproved_seconds_spent_limit.map(Duration::from_secs), |
| | step_limit: term.step_count_limit, |
| | unimproved_step_limit: term.unimproved_step_count_limit, |
| | } |
| | } else { |
| | SolverConfig::default_config() |
| | }; |
| |
|
| | let job = state.solver.create_job_with_config(id.clone(), plan, config); |
| | state.solver.start_solving(job); |
| | Ok(id) |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/route-plans", |
| | responses((status = 200, description = "List of job IDs", body = Vec<String>)) |
| | )] |
| | async fn list_route_plans(State(state): State<Arc<AppState>>) -> Json<Vec<String>> { |
| | Json(state.solver.list_jobs()) |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/route-plans/{id}", |
| | params(("id" = String, Path, description = "Route plan ID")), |
| | responses( |
| | (status = 200, description = "Route plan retrieved", body = RoutePlanDto), |
| | (status = 404, description = "Not found") |
| | ) |
| | )] |
| | async fn get_route_plan( |
| | State(state): State<Arc<AppState>>, |
| | Path(id): Path<String>, |
| | ) -> Result<Json<RoutePlanDto>, StatusCode> { |
| | match state.solver.get_job(&id) { |
| | Some(job) => { |
| | let guard = job.read(); |
| | Ok(Json(RoutePlanDto::from_plan( |
| | &guard.plan, |
| | Some(guard.status), |
| | ))) |
| | } |
| | None => Err(StatusCode::NOT_FOUND), |
| | } |
| | } |
| |
|
| | |
| | #[derive(Debug, Serialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct StatusResponse { |
| | |
| | pub score: Option<String>, |
| | |
| | pub solver_status: String, |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/route-plans/{id}/status", |
| | params(("id" = String, Path, description = "Route plan ID")), |
| | responses( |
| | (status = 200, description = "Status retrieved", body = StatusResponse), |
| | (status = 404, description = "Not found") |
| | ) |
| | )] |
| | async fn get_route_plan_status( |
| | State(state): State<Arc<AppState>>, |
| | Path(id): Path<String>, |
| | ) -> Result<Json<StatusResponse>, StatusCode> { |
| | match state.solver.get_job(&id) { |
| | Some(job) => { |
| | let guard = job.read(); |
| | Ok(Json(StatusResponse { |
| | score: guard.plan.score.map(|s| format!("{}", s)), |
| | solver_status: guard.status.as_str().to_string(), |
| | })) |
| | } |
| | None => Err(StatusCode::NOT_FOUND), |
| | } |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | delete, |
| | path = "/route-plans/{id}", |
| | params(("id" = String, Path, description = "Route plan ID")), |
| | responses( |
| | (status = 200, description = "Solving stopped", body = RoutePlanDto), |
| | (status = 404, description = "Not found") |
| | ) |
| | )] |
| | async fn stop_solving( |
| | State(state): State<Arc<AppState>>, |
| | Path(id): Path<String>, |
| | ) -> Result<Json<RoutePlanDto>, StatusCode> { |
| | state.solver.stop_solving(&id); |
| | match state.solver.remove_job(&id) { |
| | Some(job) => { |
| | let guard = job.read(); |
| | Ok(Json(RoutePlanDto::from_plan( |
| | &guard.plan, |
| | Some(SolverStatus::NotSolving), |
| | ))) |
| | } |
| | None => Err(StatusCode::NOT_FOUND), |
| | } |
| | } |
| |
|
| | |
| | #[derive(Debug, Serialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct GeometryResponse { |
| | |
| | pub segments: Vec<EncodedSegment>, |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | get, |
| | path = "/route-plans/{id}/geometry", |
| | params(("id" = String, Path, description = "Route plan ID")), |
| | responses( |
| | (status = 200, description = "Geometry retrieved", body = GeometryResponse), |
| | (status = 404, description = "Not found") |
| | ) |
| | )] |
| | async fn get_route_geometry( |
| | State(state): State<Arc<AppState>>, |
| | Path(id): Path<String>, |
| | ) -> Result<Json<GeometryResponse>, StatusCode> { |
| | match state.solver.get_job(&id) { |
| | Some(job) => { |
| | let guard = job.read(); |
| | let segments = encode_routes(&guard.plan); |
| | Ok(Json(GeometryResponse { segments })) |
| | } |
| | None => Err(StatusCode::NOT_FOUND), |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | #[derive(Debug, Clone, Serialize, ToSchema)] |
| | pub struct MatchAnalysisDto { |
| | |
| | pub name: String, |
| | |
| | pub score: String, |
| | |
| | pub justification: String, |
| | } |
| |
|
| | |
| | #[derive(Debug, Clone, Serialize, ToSchema)] |
| | pub struct ConstraintAnalysisDto { |
| | |
| | pub name: String, |
| | |
| | pub weight: String, |
| | |
| | pub score: String, |
| | |
| | pub matches: Vec<MatchAnalysisDto>, |
| | } |
| |
|
| | |
| | #[derive(Debug, Serialize, ToSchema)] |
| | pub struct AnalyzeResponse { |
| | |
| | pub constraints: Vec<ConstraintAnalysisDto>, |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | put, |
| | path = "/route-plans/analyze", |
| | request_body = RoutePlanDto, |
| | responses((status = 200, description = "Constraint analysis", body = AnalyzeResponse)) |
| | )] |
| | async fn analyze_route_plan(Json(dto): Json<RoutePlanDto>) -> Json<AnalyzeResponse> { |
| | use crate::constraints::{calculate_late_minutes, calculate_excess_capacity}; |
| |
|
| | let plan = dto.to_domain(); |
| |
|
| | |
| | let cap_total: i64 = plan.vehicles.iter() |
| | .map(|v| calculate_excess_capacity(&plan, v) as i64) |
| | .sum(); |
| |
|
| | let tw_total: i64 = plan.vehicles.iter() |
| | .map(|v| calculate_late_minutes(&plan, v)) |
| | .sum(); |
| |
|
| | let travel_total: i64 = plan.vehicles.iter() |
| | .map(|v| plan.total_driving_time(v)) |
| | .sum(); |
| |
|
| | let cap_score = HardSoftScore::of_hard(-cap_total); |
| | let tw_score = HardSoftScore::of_hard(-tw_total); |
| | let travel_score = HardSoftScore::of_soft(-travel_total); |
| |
|
| | |
| | let total_demand = |v: &Vehicle| -> i32 { |
| | v.visits.iter() |
| | .filter_map(|&idx| plan.visits.get(idx)) |
| | .map(|visit| visit.demand) |
| | .sum() |
| | }; |
| |
|
| | |
| | let cap_matches: Vec<MatchAnalysisDto> = plan.vehicles.iter() |
| | .filter(|v| total_demand(v) > v.capacity) |
| | .map(|v| { |
| | let demand = total_demand(v); |
| | let excess = demand - v.capacity; |
| | MatchAnalysisDto { |
| | name: "Vehicle capacity".to_string(), |
| | score: format!("{}hard/0soft", -excess), |
| | justification: format!("{} is over capacity by {} (demand {} > capacity {})", |
| | v.name, excess, demand, v.capacity), |
| | } |
| | }) |
| | .collect(); |
| |
|
| | |
| | let mut tw_matches: Vec<MatchAnalysisDto> = Vec::new(); |
| | for vehicle in &plan.vehicles { |
| | let timings = plan.calculate_route_times(vehicle); |
| | for timing in &timings { |
| | if let Some(visit) = plan.get_visit(timing.visit_idx) { |
| | if timing.departure > visit.max_end_time { |
| | let late_secs = timing.departure - visit.max_end_time; |
| | let late_mins = (late_secs + 59) / 60; |
| | tw_matches.push(MatchAnalysisDto { |
| | name: "Service finished after max end time".to_string(), |
| | score: format!("{}hard/0soft", -late_mins), |
| | justification: format!("{} finishes {} mins late (ends at {}, max {})", |
| | visit.name, late_mins, |
| | seconds_to_iso(timing.departure), |
| | seconds_to_iso(visit.max_end_time)), |
| | }); |
| | } |
| | } |
| | } |
| | } |
| |
|
| | |
| | let travel_matches: Vec<MatchAnalysisDto> = plan.vehicles.iter() |
| | .filter(|v| !v.visits.is_empty()) |
| | .map(|v| { |
| | let time = plan.total_driving_time(v); |
| | MatchAnalysisDto { |
| | name: "Minimize travel time".to_string(), |
| | score: format!("0hard/{}soft", -time), |
| | justification: format!("{} drives {} seconds", v.name, time), |
| | } |
| | }) |
| | .collect(); |
| |
|
| | let constraints = vec![ |
| | ConstraintAnalysisDto { |
| | name: "Vehicle capacity".to_string(), |
| | weight: "1hard/0soft".to_string(), |
| | score: format!("{}", cap_score), |
| | matches: cap_matches, |
| | }, |
| | ConstraintAnalysisDto { |
| | name: "Service finished after max end time".to_string(), |
| | weight: "1hard/0soft".to_string(), |
| | score: format!("{}", tw_score), |
| | matches: tw_matches, |
| | }, |
| | ConstraintAnalysisDto { |
| | name: "Minimize travel time".to_string(), |
| | weight: "0hard/1soft".to_string(), |
| | score: format!("{}", travel_score), |
| | matches: travel_matches, |
| | }, |
| | ]; |
| |
|
| | Json(AnalyzeResponse { constraints }) |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct VehicleRecommendation { |
| | |
| | pub vehicle_id: String, |
| | |
| | pub index: usize, |
| | } |
| |
|
| | |
| | #[derive(Debug, Clone, Serialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct RecommendedAssignment { |
| | |
| | pub proposition: VehicleRecommendation, |
| | |
| | pub score_diff: String, |
| | } |
| |
|
| | |
| | #[derive(Debug, Deserialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct RecommendationRequest { |
| | |
| | pub solution: RoutePlanDto, |
| | |
| | pub visit_id: String, |
| | } |
| |
|
| | |
| | #[derive(Debug, Deserialize, ToSchema)] |
| | #[serde(rename_all = "camelCase")] |
| | pub struct ApplyRecommendationRequest { |
| | |
| | pub solution: RoutePlanDto, |
| | |
| | pub visit_id: String, |
| | |
| | pub vehicle_id: String, |
| | |
| | pub index: usize, |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | post, |
| | path = "/route-plans/recommendation", |
| | request_body = RecommendationRequest, |
| | responses((status = 200, description = "Recommendations", body = Vec<RecommendedAssignment>)) |
| | )] |
| | async fn recommend_assignment(Json(request): Json<RecommendationRequest>) -> Json<Vec<RecommendedAssignment>> { |
| | use crate::constraints::calculate_score; |
| |
|
| | let mut plan = request.solution.to_domain(); |
| |
|
| | |
| | let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX); |
| | if visit_id_num >= plan.visits.len() { |
| | return Json(vec![]); |
| | } |
| |
|
| | |
| | for vehicle in &mut plan.vehicles { |
| | vehicle.visits.retain(|&v| v != visit_id_num); |
| | } |
| | plan.finalize(); |
| |
|
| | |
| | let baseline = calculate_score(&plan); |
| |
|
| | |
| | let mut recommendations: Vec<(RecommendedAssignment, HardSoftScore)> = Vec::new(); |
| |
|
| | for (v_idx, vehicle) in plan.vehicles.iter().enumerate() { |
| | for insert_pos in 0..=vehicle.visits.len() { |
| | |
| | let mut test_plan = plan.clone(); |
| | test_plan.vehicles[v_idx].visits.insert(insert_pos, visit_id_num); |
| | test_plan.finalize(); |
| |
|
| | let new_score = calculate_score(&test_plan); |
| | let diff = new_score - baseline; |
| |
|
| | recommendations.push(( |
| | RecommendedAssignment { |
| | proposition: VehicleRecommendation { |
| | vehicle_id: vehicle.id.to_string(), |
| | index: insert_pos, |
| | }, |
| | score_diff: format!("{}", diff), |
| | }, |
| | diff, |
| | )); |
| | } |
| | } |
| |
|
| | |
| | recommendations.sort_by(|a, b| b.1.cmp(&a.1)); |
| | let top5: Vec<RecommendedAssignment> = recommendations.into_iter().take(5).map(|(r, _)| r).collect(); |
| |
|
| | Json(top5) |
| | } |
| |
|
| | |
| | #[utoipa::path( |
| | post, |
| | path = "/route-plans/recommendation/apply", |
| | request_body = ApplyRecommendationRequest, |
| | responses((status = 200, description = "Updated solution", body = RoutePlanDto)) |
| | )] |
| | async fn apply_recommendation(Json(request): Json<ApplyRecommendationRequest>) -> Json<RoutePlanDto> { |
| | let mut plan = request.solution.to_domain(); |
| |
|
| | |
| | let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX); |
| | let vehicle_id_num: usize = request.vehicle_id.parse().unwrap_or(usize::MAX); |
| |
|
| | |
| | for vehicle in &mut plan.vehicles { |
| | vehicle.visits.retain(|&v| v != visit_id_num); |
| | } |
| |
|
| | |
| | if let Some(vehicle) = plan.vehicles.iter_mut().find(|v| v.id == vehicle_id_num) { |
| | let insert_idx = request.index.min(vehicle.visits.len()); |
| | vehicle.visits.insert(insert_idx, visit_id_num); |
| | } |
| |
|
| | plan.finalize(); |
| |
|
| | |
| | use crate::constraints::calculate_score; |
| | plan.score = Some(calculate_score(&plan)); |
| |
|
| | Json(RoutePlanDto::from_plan(&plan, None)) |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | #[derive(OpenApi)] |
| | #[openapi( |
| | paths( |
| | health, |
| | info, |
| | list_demo_data, |
| | get_demo_data, |
| | create_route_plan, |
| | list_route_plans, |
| | get_route_plan, |
| | get_route_plan_status, |
| | stop_solving, |
| | get_route_geometry, |
| | analyze_route_plan, |
| | recommend_assignment, |
| | apply_recommendation, |
| | ), |
| | components(schemas( |
| | HealthResponse, |
| | InfoResponse, |
| | VisitDto, |
| | VehicleDto, |
| | RoutePlanDto, |
| | TerminationConfigDto, |
| | StatusResponse, |
| | GeometryResponse, |
| | MatchAnalysisDto, |
| | ConstraintAnalysisDto, |
| | AnalyzeResponse, |
| | VehicleRecommendation, |
| | RecommendedAssignment, |
| | RecommendationRequest, |
| | ApplyRecommendationRequest, |
| | )) |
| | )] |
| | struct ApiDoc; |
| |
|