|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|