diff --git a/app/api/include/deliveryoptimizer/api/forecast_optimizer.hpp b/app/api/include/deliveryoptimizer/api/forecast_optimizer.hpp index b75ce315d..6ea836932 100644 --- a/app/api/include/deliveryoptimizer/api/forecast_optimizer.hpp +++ b/app/api/include/deliveryoptimizer/api/forecast_optimizer.hpp @@ -2,9 +2,12 @@ #include "deliveryoptimizer/api/optimize_request.hpp" +#include #include #include +#include #include +#include namespace deliveryoptimizer::api { @@ -17,6 +20,14 @@ struct WeatherForecastOptions { std::string openweather_base_url; }; +struct TrafficForecastOptions { + bool enabled{false}; + int reoptimize_threshold_seconds{300}; + double reoptimize_threshold_percent{5.0}; + std::string google_maps_api_key; + std::string google_maps_base_url; +}; + struct OpenWeatherDelayEstimate { bool available{false}; int delay_seconds_per_stop{0}; @@ -26,34 +37,108 @@ struct OpenWeatherDelayEstimate { struct WeatherImpactEstimate { int stop_count{0}; int baseline_duration_seconds{0}; + int baseline_route_duration_seconds{0}; int delay_seconds_per_stop{0}; int weather_delay_seconds{0}; + int weather_adjusted_duration_seconds{0}; + int reoptimize_threshold_seconds{300}; + bool should_reoptimize{false}; + std::string source; + std::optional planned_start_time; + std::optional estimated_finish_time; +}; + +struct TrafficImpact { + int baseline_duration_seconds{0}; + int traffic_delay_seconds{0}; + int traffic_adjusted_duration_seconds{0}; int reoptimize_threshold_seconds{300}; bool should_reoptimize{false}; std::string source; }; +struct TrafficDelayEstimate { + bool available{false}; + int delay_seconds{0}; + std::string source; +}; + +struct TrafficLeg { + Coordinate origin; + Coordinate destination; + std::chrono::sys_seconds departure_time; +}; + [[nodiscard]] WeatherForecastOptions ResolveWeatherForecastOptionsFromEnv(); +[[nodiscard]] TrafficForecastOptions ResolveTrafficForecastOptionsFromEnv(); + [[nodiscard]] bool IsOpenWeatherConfigured(const WeatherForecastOptions& options); [[nodiscard]] int EstimateServiceSeconds(const OptimizeRequestInput& input); -[[nodiscard]] OpenWeatherDelayEstimate -FetchOpenWeatherDelayEstimate(const WeatherForecastOptions& options, const Coordinate& coordinate); +[[nodiscard]] OpenWeatherDelayEstimate FetchOpenWeatherDelayEstimate( + const WeatherForecastOptions& options, const Coordinate& coordinate, + std::optional route_start_time = std::nullopt, + std::optional route_duration_seconds = std::nullopt); + +[[nodiscard]] int +ReadOpenWeatherDelay(const Json::Value& body, + std::optional route_start_time = std::nullopt, + std::optional route_duration_seconds = std::nullopt); + +[[nodiscard]] bool IsGoogleMapsConfigured(const TrafficForecastOptions& options); + +[[nodiscard]] std::string BuildTrafficPath(const Coordinate& origin, const Coordinate& destination, + std::chrono::sys_seconds departure_time, + const std::string& api_key); + +[[nodiscard]] std::optional ReadTrafficDelay(const Json::Value& body); + +[[nodiscard]] TrafficDelayEstimate FetchTrafficDelay(const TrafficForecastOptions& options, + const TrafficLeg& leg); + +[[nodiscard]] std::vector +ReadTrafficLegs(const Json::Value& vroom_output, + std::optional route_start_time = std::nullopt); + +[[nodiscard]] TrafficDelayEstimate +ReadRouteTraffic(const TrafficForecastOptions& options, const Json::Value& vroom_output, + std::optional route_start_time = std::nullopt); [[nodiscard]] WeatherImpactEstimate EstimateWeatherImpact(const WeatherForecastOptions& options, std::size_t stop_count, int baseline_duration_seconds); +[[nodiscard]] TrafficImpact EstimateTrafficImpact(const TrafficForecastOptions& options, + int baseline_duration_seconds, + int traffic_delay_seconds, std::string source); + [[nodiscard]] WeatherImpactEstimate EstimateRouteWeatherImpact(const WeatherForecastOptions& options, const OptimizeRequestInput& input, int baseline_duration_seconds); +[[nodiscard]] std::optional +ReadRouteStartTime(const OptimizeRequestInput& input); + +[[nodiscard]] std::optional ReadVroomDuration(const Json::Value& vroom_output); + +[[nodiscard]] WeatherImpactEstimate RecalculateWeatherImpact(const WeatherForecastOptions& options, + const OptimizeRequestInput& input, + const Json::Value& vroom_output); + [[nodiscard]] Json::Value BuildWeatherAdjustedVroomInput(const OptimizeRequestInput& input, const WeatherImpactEstimate& impact); +[[nodiscard]] Json::Value BuildTrafficAdjustedVroomInput(const OptimizeRequestInput& input, + const WeatherImpactEstimate& weather, + const TrafficImpact& traffic, + const Json::Value& vroom_output); + [[nodiscard]] Json::Value BuildWeatherForecastAnnotation(const WeatherForecastOptions& options, const WeatherImpactEstimate& impact); +void AddTrafficForecast(Json::Value& forecast, const TrafficForecastOptions& options, + const TrafficImpact& impact); + } // namespace deliveryoptimizer::api diff --git a/app/api/include/deliveryoptimizer/api/optimization_job_runtime.hpp b/app/api/include/deliveryoptimizer/api/optimization_job_runtime.hpp index 96c223716..c7a46ba0f 100644 --- a/app/api/include/deliveryoptimizer/api/optimization_job_runtime.hpp +++ b/app/api/include/deliveryoptimizer/api/optimization_job_runtime.hpp @@ -67,6 +67,7 @@ class OptimizationJobRuntime { std::shared_ptr observability_; OptimizationJobRuntimeOptions options_; WeatherForecastOptions weather_options_; + TrafficForecastOptions traffic_options_; std::deque worker_states_; std::vector workers_; std::jthread heartbeat_thread_; diff --git a/app/api/src/endpoints/deliveries_optimize_endpoint.cpp b/app/api/src/endpoints/deliveries_optimize_endpoint.cpp index d9a77fd40..c15cb8390 100644 --- a/app/api/src/endpoints/deliveries_optimize_endpoint.cpp +++ b/app/api/src/endpoints/deliveries_optimize_endpoint.cpp @@ -8,10 +8,12 @@ #include "deliveryoptimizer/api/vroom_runner.hpp" #include +#include #include #include #include #include +#include #include #include @@ -94,6 +96,70 @@ void DispatchResponse( response_loop->queueInLoop([callback, response] { (*callback)(response); }); } +void FinishWithTraffic( + const std::shared_ptr& coordinator, + std::shared_ptr optimize_request, + const deliveryoptimizer::api::SolveRequestSize request_size, + deliveryoptimizer::api::TrafficForecastOptions traffic_options, + std::optional forecast, + const deliveryoptimizer::api::WeatherImpactEstimate weather_impact, + std::function respond_with_completion, + deliveryoptimizer::api::CoordinatedSolveResult weather_result) { + if (!weather_result.output.has_value()) { + const deliveryoptimizer::api::SolveExecutionResult response_result = + deliveryoptimizer::api::BuildSolveExecutionResult(*optimize_request, weather_result, + forecast); + respond_with_completion(BuildSolveExecutionResponse(response_result)); + return; + } + + std::thread([coordinator, optimize_request = std::move(optimize_request), request_size, + traffic_options = std::move(traffic_options), forecast = std::move(forecast), + weather_impact, respond_with_completion = std::move(respond_with_completion), + weather_result = std::move(weather_result)]() mutable { + std::optional final_forecast = forecast; + const Json::Value& route_output = *weather_result.output; + + const deliveryoptimizer::api::TrafficDelayEstimate traffic_delay = + deliveryoptimizer::api::ReadRouteTraffic( + traffic_options, route_output, + deliveryoptimizer::api::ReadRouteStartTime(*optimize_request)); + const deliveryoptimizer::api::TrafficImpact traffic = + deliveryoptimizer::api::EstimateTrafficImpact( + traffic_options, deliveryoptimizer::api::ReadVroomDuration(route_output).value_or(0), + traffic_delay.delay_seconds, traffic_delay.source); + if (final_forecast.has_value()) { + deliveryoptimizer::api::AddTrafficForecast(*final_forecast, traffic_options, traffic); + } + + if (traffic.should_reoptimize) { + Json::Value route_output_copy = route_output; + const deliveryoptimizer::api::SolveAdmissionStatus traffic_rerun_status = coordinator->Submit( + request_size, + [optimize_request, weather_impact, traffic, route_output_copy] { + return deliveryoptimizer::api::BuildTrafficAdjustedVroomInput( + *optimize_request, weather_impact, traffic, route_output_copy); + }, + [optimize_request, final_forecast, respond_with_completion]( + const deliveryoptimizer::api::CoordinatedSolveResult& traffic_result) mutable { + const deliveryoptimizer::api::SolveExecutionResult response_result = + deliveryoptimizer::api::BuildSolveExecutionResult(*optimize_request, traffic_result, + final_forecast); + respond_with_completion(BuildSolveExecutionResponse(response_result)); + }); + if (traffic_rerun_status != deliveryoptimizer::api::SolveAdmissionStatus::kAccepted) { + respond_with_completion(BuildAdmissionRejectionResponse(traffic_rerun_status)); + } + return; + } + + const deliveryoptimizer::api::SolveExecutionResult response_result = + deliveryoptimizer::api::BuildSolveExecutionResult(*optimize_request, weather_result, + final_forecast); + respond_with_completion(BuildSolveExecutionResponse(response_result)); + }).detach(); +} + } // namespace namespace deliveryoptimizer::api { @@ -102,13 +168,14 @@ void RegisterDeliveriesOptimizeEndpoint(drogon::HttpAppFramework& app, const SolveAdmissionConfig& admission_config, std::shared_ptr observability) { const WeatherForecastOptions weather_options = ResolveWeatherForecastOptionsFromEnv(); - auto coordinator = std::make_shared( - admission_config, std::make_shared(ResolveVroomRuntimeConfigFromEnv()), - SolveCoordinatorOptions{}, observability); + const TrafficForecastOptions traffic_options = ResolveTrafficForecastOptionsFromEnv(); + auto runner = std::make_shared(ResolveVroomRuntimeConfigFromEnv()); + auto coordinator = std::make_shared(admission_config, runner, + SolveCoordinatorOptions{}, observability); app.registerHandler( "/api/v1/deliveries/optimize", - [coordinator = std::move(coordinator), weather_options, + [coordinator = std::move(coordinator), weather_options, traffic_options, observability = std::move(observability)]( const drogon::HttpRequestPtr& request, std::function&& callback) { @@ -174,28 +241,46 @@ void RegisterDeliveriesOptimizeEndpoint(drogon::HttpAppFramework& app, .jobs = optimize_request_ptr->jobs.size(), .vehicles = optimize_request_ptr->vehicles.size(), }; - auto weather_impact = std::make_shared>(std::nullopt); - const SolveAdmissionStatus admission_status = coordinator->Submit( - request_size, - [optimize_request_ptr, weather_options, weather_impact] { - const int baseline_seconds = EstimateServiceSeconds(*optimize_request_ptr); - const WeatherImpactEstimate impact = EstimateWeatherImpact( - weather_options, optimize_request_ptr->jobs.size(), baseline_seconds); - *weather_impact = impact; - return BuildWeatherAdjustedVroomInput(*optimize_request_ptr, impact); - }, - [optimize_request_ptr, weather_options, weather_impact, + request_size, [optimize_request_ptr] { return BuildVroomInput(*optimize_request_ptr); }, + [coordinator, optimize_request_ptr, request_size, weather_options, traffic_options, respond_with_completion](const CoordinatedSolveResult& result) mutable { std::optional forecast; - if (result.output.has_value()) { - const WeatherImpactEstimate impact = weather_impact->value_or( - EstimateWeatherImpact(weather_options, optimize_request_ptr->jobs.size(), - EstimateServiceSeconds(*optimize_request_ptr))); - forecast = BuildWeatherForecastAnnotation(weather_options, impact); + if (!result.output.has_value()) { + respond_with_completion(BuildSolveExecutionResponse( + BuildSolveExecutionResult(*optimize_request_ptr, result, forecast))); + return; + } + + WeatherForecastOptions sync_weather_options = weather_options; + // Clear the key so recalculation short-circuits OpenWeather; sync path must not + // block the event loop. + sync_weather_options.openweather_api_key.clear(); + const WeatherImpactEstimate impact = RecalculateWeatherImpact( + sync_weather_options, *optimize_request_ptr, *result.output); + forecast = BuildWeatherForecastAnnotation(sync_weather_options, impact); + + if (!impact.should_reoptimize) { + FinishWithTraffic(coordinator, optimize_request_ptr, request_size, traffic_options, + forecast, impact, respond_with_completion, result); + return; + } + + const SolveAdmissionStatus rerun_status = coordinator->Submit( + request_size, + [optimize_request_ptr, impact] { + return BuildWeatherAdjustedVroomInput(*optimize_request_ptr, impact); + }, + [coordinator, optimize_request_ptr, request_size, traffic_options, forecast, + impact, + respond_with_completion](const CoordinatedSolveResult& rerun_result) mutable { + FinishWithTraffic(coordinator, optimize_request_ptr, request_size, + traffic_options, forecast, impact, respond_with_completion, + rerun_result); + }); + if (rerun_status != SolveAdmissionStatus::kAccepted) { + respond_with_completion(BuildAdmissionRejectionResponse(rerun_status)); } - respond_with_completion(BuildSolveExecutionResponse( - BuildSolveExecutionResult(*optimize_request_ptr, result, forecast))); }, lifecycle); if (admission_status != SolveAdmissionStatus::kAccepted) { diff --git a/app/api/src/endpoints/optimization_jobs_endpoint.cpp b/app/api/src/endpoints/optimization_jobs_endpoint.cpp index d50c33d9a..ac1e89e2b 100644 --- a/app/api/src/endpoints/optimization_jobs_endpoint.cpp +++ b/app/api/src/endpoints/optimization_jobs_endpoint.cpp @@ -8,6 +8,7 @@ #include +// Windows SDK defines GetJob as a macro so it is undef before including jsoncpp. #ifdef GetJob #undef GetJob #endif diff --git a/app/api/src/forecast_optimizer.cpp b/app/api/src/forecast_optimizer.cpp index f354278de..ba80b2a1d 100644 --- a/app/api/src/forecast_optimizer.cpp +++ b/app/api/src/forecast_optimizer.cpp @@ -14,11 +14,13 @@ #include #include #include +#include #include #include #include #include #include +#include namespace { @@ -32,9 +34,20 @@ constexpr std::string_view kWeatherThresholdPercentEnv = constexpr std::string_view kOpenWeatherApiKeyEnv = "OPENWEATHER_API_KEY"; constexpr std::string_view kOpenWeatherBaseUrlEnv = "OPENWEATHER_BASE_URL"; constexpr std::string_view kDefaultOpenWeatherBaseUrl = "https://api.openweathermap.org"; +constexpr std::string_view kTrafficEnabledEnv = "DELIVERYOPTIMIZER_TRAFFIC_FORECAST_ENABLED"; +constexpr std::string_view kTrafficThresholdSecondsEnv = + "DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_SECONDS"; +constexpr std::string_view kTrafficThresholdPercentEnv = + "DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_PERCENT"; +constexpr std::string_view kGoogleMapsApiKeyEnv = "GOOGLE_MAPS_API_KEY"; +constexpr std::string_view kGoogleMapsBaseUrlEnv = "GOOGLE_MAPS_BASE_URL"; +constexpr std::string_view kDefaultGoogleMapsBaseUrl = "https://maps.googleapis.com"; constexpr int kOpenWeatherTimeoutSeconds = 4; +constexpr int kGoogleMapsTimeoutSeconds = 4; constexpr int kDefaultWeatherThresholdSeconds = 300; constexpr double kDefaultWeatherThresholdPercent = 5.0; +constexpr int kDefaultTrafficThresholdSeconds = 300; +constexpr double kDefaultTrafficThresholdPercent = 5.0; [[nodiscard]] bool IsEnabledFlag(const char* raw_value) { if (raw_value == nullptr || *raw_value == '\0') { @@ -110,49 +123,174 @@ constexpr double kDefaultWeatherThresholdPercent = 5.0; int delay_seconds = 0; const double wind_speed = hour["wind_speed"].isNumeric() ? hour["wind_speed"].asDouble() : 0.0; const int visibility = hour["visibility"].isInt() ? hour["visibility"].asInt() : 10000; + bool has_thunder = false; + const Json::Value& weather = hour["weather"]; + if (weather.isArray()) { + for (const Json::Value& condition : weather) { + const int condition_id = condition["id"].isInt() ? condition["id"].asInt() : 0; + if (condition_id >= 200 && condition_id < 300) { + has_thunder = true; + } + } + } + if (wind_speed >= 10.0) { delay_seconds += 60; } if (visibility < 5000) { delay_seconds += 60; } - if (hour["rain"].isObject()) { + if (has_thunder) { + delay_seconds += 240; + } else if (hour["rain"].isObject()) { delay_seconds += 90; } if (hour["snow"].isObject()) { delay_seconds += 180; } - const Json::Value& weather = hour["weather"]; - if (weather.isArray()) { - bool has_thunder = false; - for (const Json::Value& condition : weather) { - const int condition_id = condition["id"].isInt() ? condition["id"].asInt() : 0; - if (condition_id >= 200 && condition_id < 300) { - has_thunder = true; - } + return delay_seconds; +} + +[[nodiscard]] bool IsRouteHour(const Json::Value& hour, + const std::chrono::sys_seconds route_start_time, + const std::optional route_duration_seconds) { + if (!hour["dt"].isInt64() && !hour["dt"].isUInt64()) { + return false; + } + + const auto forecast_time = + std::chrono::sys_seconds{std::chrono::seconds{hour["dt"].asLargestInt()}}; + const int window_seconds = std::max(route_duration_seconds.value_or(6 * 60 * 60), 60 * 60); + return forecast_time >= route_start_time && + forecast_time < route_start_time + std::chrono::seconds{window_seconds}; +} + +void SetRouteTimes(const std::optional planned_start_time, + deliveryoptimizer::api::WeatherImpactEstimate& impact) { + impact.planned_start_time = planned_start_time; + if (!impact.planned_start_time.has_value()) { + impact.estimated_finish_time = std::nullopt; + return; + } + + impact.estimated_finish_time = + *impact.planned_start_time + std::chrono::seconds{impact.weather_adjusted_duration_seconds}; +} + +[[nodiscard]] std::chrono::sys_seconds +ReadLegDeparture(const Json::Value& step, + const std::optional route_start_time) { + const int arrival = step["arrival"].isInt() ? step["arrival"].asInt() : 0; + const int service = step["service"].isInt() ? step["service"].asInt() : 0; + const std::chrono::seconds offset{std::max(arrival + service, 0)}; + if (route_start_time.has_value()) { + const std::chrono::seconds route_start_seconds = + std::chrono::duration_cast(route_start_time->time_since_epoch()); + // Large arrivals are Unix timestamps; smaller arrivals are route offsets. + if (offset >= route_start_seconds - std::chrono::hours{24}) { + return std::chrono::sys_seconds{offset}; + } + + return *route_start_time + offset; + } + + return std::chrono::time_point_cast(std::chrono::system_clock::now()) + + offset; +} + +[[nodiscard]] std::vector BuildEvenTrafficDelays(const std::size_t job_count, + const int total_delay_seconds) { + std::vector delays(job_count, 0); + if (job_count == 0U || total_delay_seconds <= 0) { + return delays; + } + + const int delay_per_stop = static_cast( + std::ceil(static_cast(total_delay_seconds) / static_cast(job_count))); + std::fill(delays.begin(), delays.end(), delay_per_stop); + return delays; +} + +[[nodiscard]] std::vector ReadJobTravelSeconds(const Json::Value& vroom_output, + const std::size_t job_count) { + std::vector travel_seconds(job_count, 0); + const Json::Value& routes = vroom_output["routes"]; + if (!routes.isArray()) { + return travel_seconds; + } + + for (const Json::Value& route : routes) { + const Json::Value& steps = route["steps"]; + if (!steps.isArray() || steps.size() < 2U) { + continue; } - if (has_thunder) { - delay_seconds += 240; + + for (Json::ArrayIndex index = 1U; index < steps.size(); ++index) { + const Json::Value& from = steps[index - 1U]; + const Json::Value& to = steps[index]; + if (to["type"].isString() && to["type"].asString() != "job") { + continue; + } + if (!to["id"].isUInt64()) { + continue; + } + + const std::uint64_t raw_job_id = to["id"].asUInt64(); + if (raw_job_id == 0U || raw_job_id > job_count) { + continue; + } + + const int from_arrival = from["arrival"].isInt() ? from["arrival"].asInt() : 0; + const int from_service = from["service"].isInt() ? from["service"].asInt() : 0; + const int to_arrival = to["arrival"].isInt() ? to["arrival"].asInt() : from_arrival; + const int leg_seconds = std::max(to_arrival - from_arrival - from_service, 0); + travel_seconds[static_cast(raw_job_id - 1U)] += leg_seconds; } } - return delay_seconds; + return travel_seconds; } -[[nodiscard]] int DelayFromOpenWeatherBody(const Json::Value& body) { - const Json::Value& hourly = body["hourly"]; - if (!hourly.isArray()) { - return 0; +[[nodiscard]] std::vector BuildWeightedTrafficDelays(const Json::Value& vroom_output, + const std::size_t job_count, + const int total_delay_seconds) { + if (job_count == 0U || total_delay_seconds <= 0) { + return std::vector(job_count, 0); } - int delay_seconds = 0; - const Json::ArrayIndex hours_to_scan = std::min(hourly.size(), 6U); - for (Json::ArrayIndex index = 0; index < hours_to_scan; ++index) { - delay_seconds = std::max(delay_seconds, DelayFromHourlyForecast(hourly[index])); + const std::vector travel_seconds = ReadJobTravelSeconds(vroom_output, job_count); + const int total_travel_seconds = std::accumulate(travel_seconds.begin(), travel_seconds.end(), 0); + if (total_travel_seconds <= 0) { + return BuildEvenTrafficDelays(job_count, total_delay_seconds); } - return delay_seconds; + std::vector delays(job_count, 0); + int assigned_delay = 0; + std::vector> remainders; + remainders.reserve(job_count); + for (std::size_t index = 0U; index < job_count; ++index) { + const double raw_delay = static_cast(total_delay_seconds) * + static_cast(travel_seconds[index]) / + static_cast(total_travel_seconds); + delays[index] = static_cast(std::floor(raw_delay)); + assigned_delay += delays[index]; + remainders.emplace_back(raw_delay - static_cast(delays[index]), index); + } + + std::sort(remainders.begin(), remainders.end(), + [](const auto& left, const auto& right) { return left.first > right.first; }); + int remaining_delay = total_delay_seconds - assigned_delay; + for (const auto& remainder : remainders) { + if (remaining_delay <= 0) { + break; + } + const std::size_t index = remainder.second; + ++delays[index]; + --remaining_delay; + } + + return delays; } } // namespace @@ -176,6 +314,21 @@ WeatherForecastOptions ResolveWeatherForecastOptionsFromEnv() { }; } +TrafficForecastOptions ResolveTrafficForecastOptionsFromEnv() { + return TrafficForecastOptions{ + .enabled = IsEnabledFlag(std::getenv(kTrafficEnabledEnv.data())), + .reoptimize_threshold_seconds = + ParseNonNegativeInt(std::getenv(kTrafficThresholdSecondsEnv.data())) + .value_or(kDefaultTrafficThresholdSeconds), + .reoptimize_threshold_percent = + ParseNonNegativeDouble(std::getenv(kTrafficThresholdPercentEnv.data())) + .value_or(kDefaultTrafficThresholdPercent), + .google_maps_api_key = ResolveStringEnvOrDefault(kGoogleMapsApiKeyEnv.data(), ""), + .google_maps_base_url = + ResolveStringEnvOrDefault(kGoogleMapsBaseUrlEnv.data(), kDefaultGoogleMapsBaseUrl), + }; +} + bool IsOpenWeatherConfigured(const WeatherForecastOptions& options) { return options.enabled && !options.openweather_api_key.empty(); } @@ -192,8 +345,14 @@ int EstimateServiceSeconds(const OptimizeRequestInput& input) { return static_cast(total); } -OpenWeatherDelayEstimate FetchOpenWeatherDelayEstimate(const WeatherForecastOptions& options, - const Coordinate& coordinate) { +bool IsGoogleMapsConfigured(const TrafficForecastOptions& options) { + return options.enabled && !options.google_maps_api_key.empty(); +} + +OpenWeatherDelayEstimate +FetchOpenWeatherDelayEstimate(const WeatherForecastOptions& options, const Coordinate& coordinate, + const std::optional route_start_time, + const std::optional route_duration_seconds) { if (!IsOpenWeatherConfigured(options)) { return OpenWeatherDelayEstimate{ .available = false, @@ -211,7 +370,8 @@ OpenWeatherDelayEstimate FetchOpenWeatherDelayEstimate(const WeatherForecastOpti auto future = promise->get_future(); client->sendRequest( request, - [promise](const drogon::ReqResult result, const drogon::HttpResponsePtr& response) { + [promise, route_start_time, route_duration_seconds](const drogon::ReqResult result, + const drogon::HttpResponsePtr& response) { if (result != drogon::ReqResult::Ok || response == nullptr || response->getStatusCode() != drogon::k200OK) { promise->set_value(OpenWeatherDelayEstimate{ @@ -234,7 +394,8 @@ OpenWeatherDelayEstimate FetchOpenWeatherDelayEstimate(const WeatherForecastOpti promise->set_value(OpenWeatherDelayEstimate{ .available = true, - .delay_seconds_per_stop = DelayFromOpenWeatherBody(*body), + .delay_seconds_per_stop = + ReadOpenWeatherDelay(*body, route_start_time, route_duration_seconds), .source = "openweather", }); }, @@ -252,6 +413,204 @@ OpenWeatherDelayEstimate FetchOpenWeatherDelayEstimate(const WeatherForecastOpti return future.get(); } +int ReadOpenWeatherDelay(const Json::Value& body, + const std::optional route_start_time, + const std::optional route_duration_seconds) { + const Json::Value& hourly = body["hourly"]; + if (!hourly.isArray()) { + return 0; + } + + if (!route_start_time.has_value()) { + int fallback_delay_seconds = 0; + const Json::ArrayIndex hours_to_scan = std::min(hourly.size(), 6U); + for (Json::ArrayIndex index = 0U; index < hours_to_scan; ++index) { + fallback_delay_seconds = + std::max(fallback_delay_seconds, DelayFromHourlyForecast(hourly[index])); + } + return fallback_delay_seconds; + } + + int delay_seconds = 0; + Json::ArrayIndex matched_hours = 0U; + for (Json::ArrayIndex index = 0U; index < hourly.size(); ++index) { + if (!IsRouteHour(hourly[index], *route_start_time, route_duration_seconds)) { + continue; + } + delay_seconds = std::max(delay_seconds, DelayFromHourlyForecast(hourly[index])); + ++matched_hours; + } + + if (matched_hours > 0U) { + return delay_seconds; + } + + const Json::ArrayIndex hours_to_scan = std::min(hourly.size(), 6U); + for (Json::ArrayIndex index = 0U; index < hours_to_scan; ++index) { + delay_seconds = std::max(delay_seconds, DelayFromHourlyForecast(hourly[index])); + } + + return delay_seconds; +} + +std::string BuildTrafficPath(const Coordinate& origin, const Coordinate& destination, + const std::chrono::sys_seconds departure_time, + const std::string& api_key) { + const std::string origin_text = FormatCoordinate(origin.lat) + "," + FormatCoordinate(origin.lon); + const std::string destination_text = + FormatCoordinate(destination.lat) + "," + FormatCoordinate(destination.lon); + const auto departure_seconds = + std::chrono::duration_cast(departure_time.time_since_epoch()).count(); + + return "/maps/api/distancematrix/json?origins=" + origin_text + + "&destinations=" + destination_text + + "&departure_time=" + std::to_string(departure_seconds) + + "&traffic_model=best_guess&key=" + api_key; +} + +std::optional ReadTrafficDelay(const Json::Value& body) { + const Json::Value& rows = body["rows"]; + if (!rows.isArray() || rows.empty()) { + return std::nullopt; + } + + const Json::Value& elements = rows[0]["elements"]; + if (!elements.isArray() || elements.empty()) { + return std::nullopt; + } + + const Json::Value& leg = elements[0]; + if (leg["status"].isString() && leg["status"].asString() != "OK") { + return std::nullopt; + } + if (!leg["duration"]["value"].isInt() || !leg["duration_in_traffic"]["value"].isInt()) { + return std::nullopt; + } + + return std::max(leg["duration_in_traffic"]["value"].asInt() - leg["duration"]["value"].asInt(), + 0); +} + +TrafficDelayEstimate FetchTrafficDelay(const TrafficForecastOptions& options, + const TrafficLeg& leg) { + if (!IsGoogleMapsConfigured(options)) { + return TrafficDelayEstimate{ + .available = false, + .delay_seconds = 0, + .source = "", + }; + } + + auto client = drogon::HttpClient::newHttpClient(options.google_maps_base_url); + auto request = drogon::HttpRequest::newHttpRequest(); + request->setMethod(drogon::Get); + request->setPath(BuildTrafficPath(leg.origin, leg.destination, leg.departure_time, + options.google_maps_api_key)); + + auto promise = std::make_shared>(); + auto future = promise->get_future(); + client->sendRequest( + request, + [promise](const drogon::ReqResult result, const drogon::HttpResponsePtr& response) { + if (result != drogon::ReqResult::Ok || response == nullptr || + response->getStatusCode() != drogon::k200OK) { + promise->set_value(TrafficDelayEstimate{ + .available = false, + .delay_seconds = 0, + .source = "", + }); + return; + } + + const auto body = response->getJsonObject(); + const std::optional delay = body == nullptr ? std::nullopt : ReadTrafficDelay(*body); + promise->set_value(TrafficDelayEstimate{ + .available = delay.has_value(), + .delay_seconds = delay.value_or(0), + .source = delay.has_value() ? "google_maps" : "", + }); + }, + kGoogleMapsTimeoutSeconds); + + if (future.wait_for(std::chrono::seconds{kGoogleMapsTimeoutSeconds + 1}) != + std::future_status::ready) { + return TrafficDelayEstimate{ + .available = false, + .delay_seconds = 0, + .source = "", + }; + } + + return future.get(); +} + +std::vector +ReadTrafficLegs(const Json::Value& vroom_output, + const std::optional route_start_time) { + const Json::Value& routes = vroom_output["routes"]; + if (!routes.isArray()) { + return {}; + } + + std::vector legs; + for (const Json::Value& route : routes) { + const Json::Value& steps = route["steps"]; + if (!steps.isArray() || steps.size() < 2U) { + continue; + } + + for (Json::ArrayIndex index = 1U; index < steps.size(); ++index) { + const Json::Value& from = steps[index - 1U]; + const Json::Value& to = steps[index]; + const Json::Value& from_location = from["location"]; + const Json::Value& to_location = to["location"]; + if (!from_location.isArray() || from_location.size() != 2U || !to_location.isArray() || + to_location.size() != 2U) { + continue; + } + + legs.push_back(TrafficLeg{ + .origin = + Coordinate{.lon = from_location[0U].asDouble(), .lat = from_location[1U].asDouble()}, + .destination = + Coordinate{.lon = to_location[0U].asDouble(), .lat = to_location[1U].asDouble()}, + .departure_time = ReadLegDeparture(from, route_start_time), + }); + } + } + + return legs; +} + +TrafficDelayEstimate +ReadRouteTraffic(const TrafficForecastOptions& options, const Json::Value& vroom_output, + const std::optional route_start_time) { + if (!IsGoogleMapsConfigured(options)) { + return TrafficDelayEstimate{ + .available = false, + .delay_seconds = 0, + .source = "", + }; + } + + int delay_seconds = 0; + bool saw_traffic = false; + for (const TrafficLeg& leg : ReadTrafficLegs(vroom_output, route_start_time)) { + const TrafficDelayEstimate estimate = FetchTrafficDelay(options, leg); + if (!estimate.available) { + continue; + } + delay_seconds += estimate.delay_seconds; + saw_traffic = true; + } + + return TrafficDelayEstimate{ + .available = saw_traffic, + .delay_seconds = delay_seconds, + .source = saw_traffic ? "google_maps" : "", + }; +} + WeatherImpactEstimate EstimateWeatherImpact(const WeatherForecastOptions& options, const std::size_t stop_count, const int baseline_duration_seconds) { @@ -271,11 +630,37 @@ WeatherImpactEstimate EstimateWeatherImpact(const WeatherForecastOptions& option return WeatherImpactEstimate{ .stop_count = normalized_stop_count, .baseline_duration_seconds = normalized_baseline_seconds, + .baseline_route_duration_seconds = normalized_baseline_seconds, .delay_seconds_per_stop = configured_delay_per_stop, .weather_delay_seconds = weather_delay_seconds, + .weather_adjusted_duration_seconds = normalized_baseline_seconds + weather_delay_seconds, .reoptimize_threshold_seconds = threshold_seconds, .should_reoptimize = weather_delay_seconds > 0 && weather_delay_seconds >= threshold_seconds, .source = options.enabled ? "fixed_delay" : "disabled", + .planned_start_time = std::nullopt, + .estimated_finish_time = std::nullopt, + }; +} + +TrafficImpact EstimateTrafficImpact(const TrafficForecastOptions& options, + const int baseline_duration_seconds, + const int traffic_delay_seconds, std::string source) { + const int normalized_baseline_seconds = std::max(baseline_duration_seconds, 0); + const int normalized_delay_seconds = options.enabled ? std::max(traffic_delay_seconds, 0) : 0; + const int percent_threshold_seconds = ClampToInt(static_cast( + std::ceil(static_cast(normalized_baseline_seconds) * + (std::max(options.reoptimize_threshold_percent, 0.0) / 100.0)))); + const int threshold_seconds = + std::max(std::max(options.reoptimize_threshold_seconds, 0), percent_threshold_seconds); + + return TrafficImpact{ + .baseline_duration_seconds = normalized_baseline_seconds, + .traffic_delay_seconds = normalized_delay_seconds, + .traffic_adjusted_duration_seconds = normalized_baseline_seconds + normalized_delay_seconds, + .reoptimize_threshold_seconds = threshold_seconds, + .should_reoptimize = + normalized_delay_seconds > 0 && normalized_delay_seconds >= threshold_seconds, + .source = options.enabled ? std::move(source) : "disabled", }; } @@ -285,17 +670,61 @@ WeatherImpactEstimate EstimateRouteWeatherImpact(const WeatherForecastOptions& o WeatherForecastOptions effective_options = options; WeatherImpactEstimate impact = EstimateWeatherImpact(effective_options, input.jobs.size(), baseline_duration_seconds); + const std::optional route_start_time = ReadRouteStartTime(input); + SetRouteTimes(route_start_time, impact); const OpenWeatherDelayEstimate openweather = FetchOpenWeatherDelayEstimate( - options, Coordinate{.lon = input.depot_lon, .lat = input.depot_lat}); + effective_options, Coordinate{.lon = input.depot_lon, .lat = input.depot_lat}, + route_start_time, baseline_duration_seconds); if (openweather.available) { effective_options.weather_delay_seconds_per_stop = openweather.delay_seconds_per_stop; impact = EstimateWeatherImpact(effective_options, input.jobs.size(), baseline_duration_seconds); impact.source = openweather.source; + SetRouteTimes(route_start_time, impact); } return impact; } +std::optional ReadRouteStartTime(const OptimizeRequestInput& input) { + std::optional planned_start; + for (const VehicleInput& vehicle : input.vehicles) { + if (!vehicle.time_window.has_value()) { + continue; + } + if (!planned_start.has_value() || vehicle.time_window->start < *planned_start) { + planned_start = vehicle.time_window->start; + } + } + + return planned_start; +} + +std::optional ReadVroomDuration(const Json::Value& vroom_output) { + const Json::Value& duration = vroom_output["summary"]["duration"]; + if (!duration.isNumeric()) { + return std::nullopt; + } + + const double raw_duration = duration.asDouble(); + if (raw_duration < 0.0 || raw_duration > static_cast(std::numeric_limits::max())) { + return std::nullopt; + } + + return static_cast(std::ceil(raw_duration)); +} + +WeatherImpactEstimate RecalculateWeatherImpact(const WeatherForecastOptions& options, + const OptimizeRequestInput& input, + const Json::Value& vroom_output) { + // Callers choose whether OpenWeather may be queried by passing or clearing the API key. + const std::optional summary_duration = ReadVroomDuration(vroom_output); + if (!summary_duration.has_value()) { + return EstimateRouteWeatherImpact(options, input, 0); + } + + return EstimateRouteWeatherImpact(options, input, *summary_duration); +} + Json::Value BuildWeatherAdjustedVroomInput(const OptimizeRequestInput& input, const WeatherImpactEstimate& impact) { Json::Value payload = BuildVroomInput(input); @@ -313,17 +742,44 @@ Json::Value BuildWeatherAdjustedVroomInput(const OptimizeRequestInput& input, return payload; } +Json::Value BuildTrafficAdjustedVroomInput(const OptimizeRequestInput& input, + const WeatherImpactEstimate& weather, + const TrafficImpact& traffic, + const Json::Value& vroom_output) { + Json::Value payload = BuildWeatherAdjustedVroomInput(input, weather); + if (!traffic.should_reoptimize || input.jobs.empty()) { + return payload; + } + + const std::vector traffic_delays = + BuildWeightedTrafficDelays(vroom_output, input.jobs.size(), traffic.traffic_delay_seconds); + for (Json::ArrayIndex index = 0; index < payload["jobs"].size(); ++index) { + Json::Value& job = payload["jobs"][index]; + const int current_service = job["service"].isInt() ? job["service"].asInt() : 0; + job["service"] = current_service + traffic_delays[static_cast(index)]; + } + + return payload; +} + Json::Value BuildWeatherForecastAnnotation(const WeatherForecastOptions& options, const WeatherImpactEstimate& impact) { Json::Value forecast{Json::objectValue}; forecast["status"] = options.enabled ? "evaluated" : "disabled"; forecast["provider"] = impact.source; forecast["stop_count"] = impact.stop_count; - forecast["baseline_duration_seconds"] = impact.baseline_duration_seconds; + forecast["baseline_route_duration_seconds"] = impact.baseline_route_duration_seconds; forecast["weather_delay_seconds"] = impact.weather_delay_seconds; - forecast["predicted_duration_seconds"] = - impact.baseline_duration_seconds + impact.weather_delay_seconds; + forecast["weather_adjusted_duration_seconds"] = impact.weather_adjusted_duration_seconds; forecast["reoptimize_threshold_seconds"] = impact.reoptimize_threshold_seconds; + if (impact.planned_start_time.has_value()) { + forecast["planned_start_time"] = + static_cast(impact.planned_start_time->time_since_epoch().count()); + } + if (impact.estimated_finish_time.has_value()) { + forecast["estimated_finish_time"] = + static_cast(impact.estimated_finish_time->time_since_epoch().count()); + } Json::Value reoptimization{Json::objectValue}; reoptimization["applied"] = impact.should_reoptimize; @@ -334,4 +790,22 @@ Json::Value BuildWeatherForecastAnnotation(const WeatherForecastOptions& options return forecast; } +void AddTrafficForecast(Json::Value& forecast, const TrafficForecastOptions& options, + const TrafficImpact& impact) { + Json::Value traffic{Json::objectValue}; + traffic["status"] = options.enabled ? "evaluated" : "disabled"; + traffic["provider"] = impact.source; + traffic["baseline_duration_seconds"] = impact.baseline_duration_seconds; + traffic["traffic_delay_seconds"] = impact.traffic_delay_seconds; + traffic["traffic_adjusted_duration_seconds"] = impact.traffic_adjusted_duration_seconds; + traffic["reoptimize_threshold_seconds"] = impact.reoptimize_threshold_seconds; + + Json::Value reoptimization{Json::objectValue}; + reoptimization["applied"] = impact.should_reoptimize; + reoptimization["reason"] = impact.should_reoptimize ? "traffic_delay_crossed_threshold" + : "traffic_delay_below_threshold"; + traffic["reoptimization"] = std::move(reoptimization); + forecast["traffic"] = std::move(traffic); +} + } // namespace deliveryoptimizer::api diff --git a/app/api/src/optimization_job_runtime.cpp b/app/api/src/optimization_job_runtime.cpp index fcd08f51d..71b854f83 100644 --- a/app/api/src/optimization_job_runtime.cpp +++ b/app/api/src/optimization_job_runtime.cpp @@ -25,7 +25,8 @@ OptimizationJobRuntime::OptimizationJobRuntime(std::shared_ptrIsConfigured()) { schema_ready_ = store_->EnsureSchema(&schema_status_detail_); } @@ -154,13 +155,33 @@ void OptimizationJobRuntime::WorkerLoop(const std::stop_token stop_token, } } } else { - const int baseline_seconds = EstimateServiceSeconds(parsed_request->input); - const WeatherImpactEstimate impact = - EstimateRouteWeatherImpact(weather_options_, parsed_request->input, baseline_seconds); - const Json::Value vroom_input = BuildWeatherAdjustedVroomInput(parsed_request->input, impact); - const auto solve_result = BuildSolveExecutionResult( - parsed_request->input, ToCoordinatedSolveResult(runner_->Run(vroom_input)), - BuildWeatherForecastAnnotation(weather_options_, impact)); + const CoordinatedSolveResult coordinated_result = + ToCoordinatedSolveResult(runner_->Run(BuildVroomInput(parsed_request->input))); + CoordinatedSolveResult final_result = coordinated_result; + std::optional forecast; + if (coordinated_result.output.has_value()) { + const WeatherImpactEstimate impact = RecalculateWeatherImpact( + weather_options_, parsed_request->input, *coordinated_result.output); + forecast = BuildWeatherForecastAnnotation(weather_options_, impact); + if (impact.should_reoptimize) { + final_result = ToCoordinatedSolveResult( + runner_->Run(BuildWeatherAdjustedVroomInput(parsed_request->input, impact))); + } + if (final_result.output.has_value()) { + const TrafficDelayEstimate traffic_delay = ReadRouteTraffic( + traffic_options_, *final_result.output, ReadRouteStartTime(parsed_request->input)); + const TrafficImpact traffic = EstimateTrafficImpact( + traffic_options_, ReadVroomDuration(*final_result.output).value_or(0), + traffic_delay.delay_seconds, traffic_delay.source); + AddTrafficForecast(*forecast, traffic_options_, traffic); + if (traffic.should_reoptimize) { + final_result = ToCoordinatedSolveResult(runner_->Run(BuildTrafficAdjustedVroomInput( + parsed_request->input, impact, traffic, *final_result.output))); + } + } + } + const auto solve_result = + BuildSolveExecutionResult(parsed_request->input, final_result, forecast); if (solve_result.response_body.has_value()) { if (store_->CompleteJobSuccess(claimed_job->record.job_id, claimed_job->worker_id, *solve_result.response_body, solve_result.outcome, diff --git a/app/api/src/optimization_job_store.cpp b/app/api/src/optimization_job_store.cpp index 8198c2632..274b7c5b2 100644 --- a/app/api/src/optimization_job_store.cpp +++ b/app/api/src/optimization_job_store.cpp @@ -7,6 +7,7 @@ #include #include +// Windows SDK defines GetJob as a macro so it is undef before including jsoncpp. #ifdef GetJob #undef GetJob #endif diff --git a/app/ui/src/app/driver_assist/summary/page.tsx b/app/ui/src/app/driver_assist/summary/page.tsx index 449bdf678..3f09b54b6 100644 --- a/app/ui/src/app/driver_assist/summary/page.tsx +++ b/app/ui/src/app/driver_assist/summary/page.tsx @@ -1,10 +1,9 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { downloadRouteSummary } from "@/lib/driver-route/exportSummary"; -import type { DriverRoute } from "@/lib/driver-route/types"; import DriverFooter from "../components/DriverFooter"; import { WarningIcon } from "../components/icons"; @@ -13,27 +12,28 @@ import SummaryStatBlock from "./components/SummaryStatBlock"; import SummaryStopCard from "./components/SummaryStopCard"; import { summaryStyles as styles } from "./styles"; +function subscribeToRouteStore() { + return () => undefined; +} + +function readEmptyRoute() { + return null; +} + export default function DriverAssistSummaryPage() { const router = useRouter(); - const [route, setRoute] = useState(null); - const [hasCheckedRoute, setHasCheckedRoute] = useState(false); + const route = useSyncExternalStore( + subscribeToRouteStore, + readSavedRoute, + readEmptyRoute, + ); const [exportMessage, setExportMessage] = useState(null); useEffect(() => { - // Let the active route page finish its final localStorage write before the - // summary reads it back after the Finish tap. - const timeoutId = window.setTimeout(() => { - const savedRoute = readSavedRoute(); - setRoute(savedRoute); - setHasCheckedRoute(true); - - if (!savedRoute) { - router.replace("/upload-route"); - } - }, 0); - - return () => window.clearTimeout(timeoutId); - }, [router]); + if (!route) { + router.replace("/upload-route"); + } + }, [route, router]); const totals = useMemo(() => { // Failed stops count as remaining because they still need office review. @@ -58,7 +58,7 @@ export default function DriverAssistSummaryPage() { ); }; - if (!hasCheckedRoute || !route) { + if (!route) { // Keep the transition from the driver screen visually quiet. return
; } diff --git a/app/ui/src/app/manifest.ts b/app/ui/src/app/manifest.ts index 315604c44..1a2fccc9c 100644 --- a/app/ui/src/app/manifest.ts +++ b/app/ui/src/app/manifest.ts @@ -22,7 +22,7 @@ export default function manifest(): MetadataRoute.Manifest { src: "/pwa-icon.svg", sizes: "any", type: "image/svg+xml", - purpose: "maskable", + purpose: "any", }, ], }; diff --git a/deploy/compose/docker-compose.arm64.yml b/deploy/compose/docker-compose.arm64.yml index fe32cafe1..cde2f87cb 100644 --- a/deploy/compose/docker-compose.arm64.yml +++ b/deploy/compose/docker-compose.arm64.yml @@ -89,6 +89,11 @@ services: DELIVERYOPTIMIZER_WEATHER_REOPTIMIZE_THRESHOLD_PERCENT: ${DELIVERYOPTIMIZER_WEATHER_REOPTIMIZE_THRESHOLD_PERCENT:-5} OPENWEATHER_API_KEY: ${OPENWEATHER_API_KEY:-} OPENWEATHER_BASE_URL: ${OPENWEATHER_BASE_URL:-https://api.openweathermap.org} + DELIVERYOPTIMIZER_TRAFFIC_FORECAST_ENABLED: ${DELIVERYOPTIMIZER_TRAFFIC_FORECAST_ENABLED:-0} + DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_SECONDS: ${DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_SECONDS:-300} + DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_PERCENT: ${DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_PERCENT:-5} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-} + GOOGLE_MAPS_BASE_URL: ${GOOGLE_MAPS_BASE_URL:-https://maps.googleapis.com} depends_on: postgres: condition: service_healthy diff --git a/deploy/env/http-server.arm64.env b/deploy/env/http-server.arm64.env index 9a238cded..a2131bb0a 100644 --- a/deploy/env/http-server.arm64.env +++ b/deploy/env/http-server.arm64.env @@ -43,3 +43,8 @@ DELIVERYOPTIMIZER_WEATHER_REOPTIMIZE_THRESHOLD_SECONDS=300 DELIVERYOPTIMIZER_WEATHER_REOPTIMIZE_THRESHOLD_PERCENT=5 OPENWEATHER_API_KEY= OPENWEATHER_BASE_URL=https://api.openweathermap.org +DELIVERYOPTIMIZER_TRAFFIC_FORECAST_ENABLED=0 +DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_SECONDS=300 +DELIVERYOPTIMIZER_TRAFFIC_REOPTIMIZE_THRESHOLD_PERCENT=5 +GOOGLE_MAPS_API_KEY= +GOOGLE_MAPS_BASE_URL=https://maps.googleapis.com diff --git a/tests/api/forecast_optimizer/weather_forecast_optimizer_test.cpp b/tests/api/forecast_optimizer/weather_forecast_optimizer_test.cpp index 008fb481f..dc9cd457d 100644 --- a/tests/api/forecast_optimizer/weather_forecast_optimizer_test.cpp +++ b/tests/api/forecast_optimizer/weather_forecast_optimizer_test.cpp @@ -42,6 +42,26 @@ namespace { }; } +[[nodiscard]] Json::Value BuildWeatherHour(const int dt, const int condition_id) { + Json::Value hour{Json::objectValue}; + hour["dt"] = dt; + hour["wind_speed"] = 0.0; + hour["visibility"] = 10000; + + Json::Value condition{Json::objectValue}; + condition["id"] = condition_id; + hour["weather"] = Json::Value{Json::arrayValue}; + hour["weather"].append(condition); + return hour; +} + +[[nodiscard]] Json::Value BuildRainyThunderHour() { + Json::Value hour = BuildWeatherHour(0, 201); + hour["rain"] = Json::Value{Json::objectValue}; + hour["rain"]["1h"] = 2.0; + return hour; +} + } // namespace TEST(WeatherForecastOptimizerTest, DisabledWeatherHasNoImpact) { @@ -106,3 +126,368 @@ TEST(WeatherForecastOptimizerTest, AboveThresholdWeatherAddsServiceTime) { EXPECT_EQ(forecast["weather_delay_seconds"].asInt(), 400); EXPECT_TRUE(forecast["reoptimization"]["applied"].asBool()); } +TEST(WeatherForecastOptimizerTest, ReadsVroomSummaryDuration) { + Json::Value output{Json::objectValue}; + output["summary"] = Json::Value{Json::objectValue}; + output["summary"]["duration"] = 124.2; + + const std::optional duration = deliveryoptimizer::api::ReadVroomDuration(output); + + ASSERT_TRUE(duration.has_value()); + EXPECT_EQ(*duration, 125); +} + +TEST(WeatherForecastOptimizerTest, IgnoresMissingVroomSummaryDuration) { + const Json::Value output{Json::objectValue}; + + EXPECT_FALSE(deliveryoptimizer::api::ReadVroomDuration(output).has_value()); +} + +TEST(WeatherForecastOptimizerTest, ReadsEarliestVehicleStartTime) { + auto input = BuildInput(); + input.vehicles.push_back(deliveryoptimizer::api::VehicleInput{ + .external_id = "driver-2", + .capacity = 8, + .start = std::nullopt, + .end = std::nullopt, + .time_window = + deliveryoptimizer::api::TimeWindow{ + .start = std::chrono::sys_seconds{std::chrono::seconds{1800}}, + .end = std::chrono::sys_seconds{std::chrono::seconds{7200}}, + }, + }); + input.vehicles[0].time_window = deliveryoptimizer::api::TimeWindow{ + .start = std::chrono::sys_seconds{std::chrono::seconds{900}}, + .end = std::chrono::sys_seconds{std::chrono::seconds{3600}}, + }; + + const std::optional planned_start = + deliveryoptimizer::api::ReadRouteStartTime(input); + + ASSERT_TRUE(planned_start.has_value()); + EXPECT_EQ(planned_start->time_since_epoch(), std::chrono::seconds{900}); +} + +TEST(WeatherForecastOptimizerTest, MissingVehicleTimeWindowHasNoPlannedStart) { + const auto input = BuildInput(); + + EXPECT_FALSE(deliveryoptimizer::api::ReadRouteStartTime(input).has_value()); +} + +TEST(WeatherForecastOptimizerTest, ReadsOpenWeatherHourNearRouteStart) { + Json::Value body{Json::objectValue}; + body["hourly"] = Json::Value{Json::arrayValue}; + body["hourly"].append(BuildWeatherHour(0, 201)); + body["hourly"].append(BuildWeatherHour(7200, 800)); + body["hourly"].append(BuildWeatherHour(10800, 201)); + + const int delay = deliveryoptimizer::api::ReadOpenWeatherDelay( + body, std::chrono::sys_seconds{std::chrono::seconds{7200}}, 1800); + + EXPECT_EQ(delay, 0); +} + +TEST(WeatherForecastOptimizerTest, ReadsBadOpenWeatherHourNearRouteStart) { + Json::Value body{Json::objectValue}; + body["hourly"] = Json::Value{Json::arrayValue}; + body["hourly"].append(BuildWeatherHour(0, 800)); + body["hourly"].append(BuildWeatherHour(7200, 201)); + + const int delay = deliveryoptimizer::api::ReadOpenWeatherDelay( + body, std::chrono::sys_seconds{std::chrono::seconds{7200}}, 1800); + + EXPECT_EQ(delay, 240); +} + +TEST(WeatherForecastOptimizerTest, ThunderDoesNotAlsoChargeRainDelay) { + Json::Value body{Json::objectValue}; + body["hourly"] = Json::Value{Json::arrayValue}; + body["hourly"].append(BuildRainyThunderHour()); + + const int delay = deliveryoptimizer::api::ReadOpenWeatherDelay(body); + + EXPECT_EQ(delay, 240); +} + +TEST(WeatherForecastOptimizerTest, RefinesForecastWithVroomSummaryDuration) { + auto input = BuildInput(); + input.vehicles[0].time_window = deliveryoptimizer::api::TimeWindow{ + .start = std::chrono::sys_seconds{std::chrono::seconds{600}}, + .end = std::chrono::sys_seconds{std::chrono::seconds{3600}}, + }; + const deliveryoptimizer::api::WeatherForecastOptions options{ + .enabled = true, + .weather_delay_seconds_per_stop = 200, + .reoptimize_threshold_seconds = 100, + .reoptimize_threshold_percent = 0.0, + .openweather_api_key = "", + .openweather_base_url = "", + }; + Json::Value output{Json::objectValue}; + output["summary"] = Json::Value{Json::objectValue}; + output["summary"]["duration"] = 960; + + const deliveryoptimizer::api::WeatherImpactEstimate impact = + deliveryoptimizer::api::RecalculateWeatherImpact(options, input, output); + const Json::Value forecast = + deliveryoptimizer::api::BuildWeatherForecastAnnotation(options, impact); + + EXPECT_FALSE(forecast.isMember("baseline_duration_seconds")); + EXPECT_EQ(forecast["baseline_route_duration_seconds"].asInt(), 960); + EXPECT_EQ(forecast["weather_delay_seconds"].asInt(), 400); + EXPECT_EQ(forecast["weather_adjusted_duration_seconds"].asInt(), 1360); + EXPECT_FALSE(forecast.isMember("predicted_duration_seconds")); + EXPECT_EQ(forecast["planned_start_time"].asInt64(), 600); + EXPECT_EQ(forecast["estimated_finish_time"].asInt64(), 1960); +} + +TEST(TrafficForecastOptimizerTest, DisabledTrafficHasNoImpact) { + const deliveryoptimizer::api::TrafficForecastOptions options{ + .enabled = false, + .reoptimize_threshold_seconds = 100, + .reoptimize_threshold_percent = 0.0, + .google_maps_api_key = "", + .google_maps_base_url = "", + }; + + const deliveryoptimizer::api::TrafficImpact impact = + deliveryoptimizer::api::EstimateTrafficImpact(options, 900, 300, "google_maps"); + + EXPECT_EQ(impact.traffic_delay_seconds, 0); + EXPECT_FALSE(impact.should_reoptimize); + EXPECT_EQ(impact.source, "disabled"); +} + +TEST(TrafficForecastOptimizerTest, BelowThresholdTrafficDoesNotReoptimize) { + const deliveryoptimizer::api::TrafficForecastOptions options{ + .enabled = true, + .reoptimize_threshold_seconds = 300, + .reoptimize_threshold_percent = 0.0, + .google_maps_api_key = "", + .google_maps_base_url = "", + }; + + const deliveryoptimizer::api::TrafficImpact impact = + deliveryoptimizer::api::EstimateTrafficImpact(options, 900, 120, "google_maps"); + + EXPECT_EQ(impact.traffic_delay_seconds, 120); + EXPECT_EQ(impact.traffic_adjusted_duration_seconds, 1020); + EXPECT_FALSE(impact.should_reoptimize); +} + +TEST(TrafficForecastOptimizerTest, AboveThresholdTrafficReoptimizes) { + const deliveryoptimizer::api::TrafficForecastOptions options{ + .enabled = true, + .reoptimize_threshold_seconds = 100, + .reoptimize_threshold_percent = 0.0, + .google_maps_api_key = "", + .google_maps_base_url = "", + }; + + const deliveryoptimizer::api::TrafficImpact impact = + deliveryoptimizer::api::EstimateTrafficImpact(options, 900, 180, "google_maps"); + + EXPECT_EQ(impact.traffic_delay_seconds, 180); + EXPECT_TRUE(impact.should_reoptimize); +} +TEST(TrafficForecastOptimizerTest, AboveThresholdTrafficAddsServiceTime) { + const auto input = BuildInput(); + Json::Value output{Json::objectValue}; + output["routes"] = Json::Value{Json::arrayValue}; + Json::Value route{Json::objectValue}; + route["steps"] = Json::Value{Json::arrayValue}; + + Json::Value start{Json::objectValue}; + start["arrival"] = 0; + start["location"] = Json::Value{Json::arrayValue}; + start["location"].append(-121.7405); + start["location"].append(38.5449); + + Json::Value first_stop{Json::objectValue}; + first_stop["type"] = "job"; + first_stop["id"] = 1; + first_stop["arrival"] = 300; + first_stop["service"] = 180; + first_stop["location"] = Json::Value{Json::arrayValue}; + first_stop["location"].append(-121.748); + first_stop["location"].append(38.545); + + Json::Value second_stop{Json::objectValue}; + second_stop["type"] = "job"; + second_stop["id"] = 2; + second_stop["arrival"] = 1080; + second_stop["service"] = 120; + second_stop["location"] = Json::Value{Json::arrayValue}; + second_stop["location"].append(-121.752); + second_stop["location"].append(38.548); + + route["steps"].append(start); + route["steps"].append(first_stop); + route["steps"].append(second_stop); + output["routes"].append(route); + + const deliveryoptimizer::api::WeatherImpactEstimate weather{}; + const deliveryoptimizer::api::TrafficImpact traffic{ + .baseline_duration_seconds = 900, + .traffic_delay_seconds = 180, + .traffic_adjusted_duration_seconds = 1080, + .reoptimize_threshold_seconds = 100, + .should_reoptimize = true, + .source = "google_maps", + }; + + const Json::Value payload = + deliveryoptimizer::api::BuildTrafficAdjustedVroomInput(input, weather, traffic, output); + + ASSERT_TRUE(payload["jobs"].isArray()); + ASSERT_EQ(payload["jobs"].size(), 2U); + EXPECT_EQ(payload["jobs"][0]["service"].asInt(), 240); + EXPECT_EQ(payload["jobs"][1]["service"].asInt(), 240); +} +TEST(TrafficForecastOptimizerTest, BuildsGoogleTrafficPath) { + const std::string path = deliveryoptimizer::api::BuildTrafficPath( + deliveryoptimizer::api::Coordinate{.lon = -121.7405, .lat = 38.5449}, + deliveryoptimizer::api::Coordinate{.lon = -121.752, .lat = 38.548}, + std::chrono::sys_seconds{std::chrono::seconds{1800}}, "test-key"); + + EXPECT_NE(path.find("/maps/api/distancematrix/json?"), std::string::npos); + EXPECT_NE(path.find("origins=38.544900,-121.740500"), std::string::npos); + EXPECT_NE(path.find("destinations=38.548000,-121.752000"), std::string::npos); + EXPECT_NE(path.find("departure_time=1800"), std::string::npos); + EXPECT_NE(path.find("traffic_model=best_guess"), std::string::npos); + EXPECT_NE(path.find("key=test-key"), std::string::npos); +} + +TEST(TrafficForecastOptimizerTest, ReadsGoogleTrafficDelay) { + Json::Value body{Json::objectValue}; + body["rows"] = Json::Value{Json::arrayValue}; + Json::Value row{Json::objectValue}; + row["elements"] = Json::Value{Json::arrayValue}; + Json::Value leg{Json::objectValue}; + leg["status"] = "OK"; + leg["duration"]["value"] = 600; + leg["duration_in_traffic"]["value"] = 780; + row["elements"].append(leg); + body["rows"].append(row); + + const std::optional delay = deliveryoptimizer::api::ReadTrafficDelay(body); + + ASSERT_TRUE(delay.has_value()); + EXPECT_EQ(*delay, 180); +} + +TEST(TrafficForecastOptimizerTest, IgnoresMissingTrafficDuration) { + const Json::Value body{Json::objectValue}; + + EXPECT_FALSE(deliveryoptimizer::api::ReadTrafficDelay(body).has_value()); +} +TEST(TrafficForecastOptimizerTest, AddsTrafficForecastBlock) { + Json::Value forecast{Json::objectValue}; + const deliveryoptimizer::api::TrafficForecastOptions options{ + .enabled = true, + .reoptimize_threshold_seconds = 100, + .reoptimize_threshold_percent = 0.0, + .google_maps_api_key = "", + .google_maps_base_url = "", + }; + const deliveryoptimizer::api::TrafficImpact impact{ + .baseline_duration_seconds = 900, + .traffic_delay_seconds = 180, + .traffic_adjusted_duration_seconds = 1080, + .reoptimize_threshold_seconds = 100, + .should_reoptimize = true, + .source = "google_maps", + }; + + deliveryoptimizer::api::AddTrafficForecast(forecast, options, impact); + + EXPECT_EQ(forecast["traffic"]["status"].asString(), "evaluated"); + EXPECT_EQ(forecast["traffic"]["provider"].asString(), "google_maps"); + EXPECT_EQ(forecast["traffic"]["traffic_delay_seconds"].asInt(), 180); + EXPECT_TRUE(forecast["traffic"]["reoptimization"]["applied"].asBool()); +} +TEST(TrafficForecastOptimizerTest, ReadsTrafficLegsFromVroomSteps) { + // These arrivals already include the route start time. + constexpr int kRouteStart = 1767225600; + + Json::Value output{Json::objectValue}; + output["routes"] = Json::Value{Json::arrayValue}; + + Json::Value route{Json::objectValue}; + route["steps"] = Json::Value{Json::arrayValue}; + + Json::Value start{Json::objectValue}; + start["arrival"] = kRouteStart; + start["location"] = Json::Value{Json::arrayValue}; + start["location"].append(-121.7405); + start["location"].append(38.5449); + + Json::Value stop{Json::objectValue}; + stop["arrival"] = kRouteStart + 600; + stop["service"] = 120; + stop["location"] = Json::Value{Json::arrayValue}; + stop["location"].append(-121.752); + stop["location"].append(38.548); + + Json::Value end{Json::objectValue}; + end["arrival"] = kRouteStart + 1200; + end["location"] = Json::Value{Json::arrayValue}; + end["location"].append(-121.7405); + end["location"].append(38.5449); + + route["steps"].append(start); + route["steps"].append(stop); + route["steps"].append(end); + output["routes"].append(route); + + const std::vector legs = + deliveryoptimizer::api::ReadTrafficLegs( + output, std::chrono::sys_seconds{std::chrono::seconds{kRouteStart}}); + + ASSERT_EQ(legs.size(), 2U); + EXPECT_EQ(legs[0].departure_time.time_since_epoch(), std::chrono::seconds{kRouteStart}); + EXPECT_EQ(legs[1].departure_time.time_since_epoch(), std::chrono::seconds{kRouteStart + 720}); + EXPECT_DOUBLE_EQ(legs[0].origin.lon, -121.7405); + EXPECT_DOUBLE_EQ(legs[0].destination.lon, -121.752); +} +TEST(TrafficForecastOptimizerTest, ReadsRelativeTrafficLegDepartures) { + constexpr int kRouteStart = 1767225600; + + Json::Value output{Json::objectValue}; + output["routes"] = Json::Value{Json::arrayValue}; + + Json::Value route{Json::objectValue}; + route["steps"] = Json::Value{Json::arrayValue}; + + Json::Value start{Json::objectValue}; + start["arrival"] = 0; + start["location"] = Json::Value{Json::arrayValue}; + start["location"].append(-121.7405); + start["location"].append(38.5449); + + Json::Value stop{Json::objectValue}; + stop["arrival"] = 600; + stop["service"] = 120; + stop["location"] = Json::Value{Json::arrayValue}; + stop["location"].append(-121.752); + stop["location"].append(38.548); + + Json::Value end{Json::objectValue}; + end["arrival"] = 1200; + end["location"] = Json::Value{Json::arrayValue}; + end["location"].append(-121.7405); + end["location"].append(38.5449); + + route["steps"].append(start); + route["steps"].append(stop); + route["steps"].append(end); + output["routes"].append(route); + + const std::vector legs = + deliveryoptimizer::api::ReadTrafficLegs( + output, std::chrono::sys_seconds{std::chrono::seconds{kRouteStart}}); + + ASSERT_EQ(legs.size(), 2U); + EXPECT_EQ(legs[0].departure_time.time_since_epoch(), std::chrono::seconds{kRouteStart}); + EXPECT_EQ(legs[1].departure_time.time_since_epoch(), std::chrono::seconds{kRouteStart + 720}); +}