diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index bd66266a264..17417539a3c 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -2628,14 +2628,6 @@ paths: type: string enum: - success - - type: object - required: - - status - properties: - status: - type: string - enum: - - path_not_found - type: object required: - pathfinding_result @@ -4265,11 +4257,6 @@ components: - $ref: '#/components/schemas/EditoastRollingStockErrorKeyNotFound' - $ref: '#/components/schemas/EditoastRollingStockErrorLiveryMultipartError' - $ref: '#/components/schemas/EditoastRollingStockErrorNameAlreadyUsed' - - $ref: '#/components/schemas/EditoastSTDCMErrorInfraNotFound' - - $ref: '#/components/schemas/EditoastSTDCMErrorInvalidPathItems' - - $ref: '#/components/schemas/EditoastSTDCMErrorRollingStockNotFound' - - $ref: '#/components/schemas/EditoastSTDCMErrorTimetableNotFound' - - $ref: '#/components/schemas/EditoastSTDCMErrorTowedRollingStockNotFound' - $ref: '#/components/schemas/EditoastScenarioErrorInfraNotFound' - $ref: '#/components/schemas/EditoastScenarioErrorNotFound' - $ref: '#/components/schemas/EditoastScenarioErrorTimetableNotFound' @@ -4277,6 +4264,11 @@ components: - $ref: '#/components/schemas/EditoastSearchApiErrorSearchEngineError' - $ref: '#/components/schemas/EditoastSpriteErrorsFileNotFound' - $ref: '#/components/schemas/EditoastSpriteErrorsUnknownSignalingSystem' + - $ref: '#/components/schemas/EditoastStdcmErrorInfraNotFound' + - $ref: '#/components/schemas/EditoastStdcmErrorInvalidPathItems' + - $ref: '#/components/schemas/EditoastStdcmErrorRollingStockNotFound' + - $ref: '#/components/schemas/EditoastStdcmErrorTimetableNotFound' + - $ref: '#/components/schemas/EditoastStdcmErrorTowedRollingStockNotFound' - $ref: '#/components/schemas/EditoastStudyErrorNotFound' - $ref: '#/components/schemas/EditoastStudyErrorStartDateAfterEndDate' - $ref: '#/components/schemas/EditoastTemporarySpeedLimitErrorNameAlreadyUsed' @@ -5164,7 +5156,7 @@ components: type: string enum: - editoast:rollingstocks:NameAlreadyUsed - EditoastSTDCMErrorInfraNotFound: + EditoastScenarioErrorInfraNotFound: type: object required: - type @@ -5183,12 +5175,12 @@ components: status: type: integer enum: - - 400 + - 404 type: type: string enum: - - editoast:stdcm_v2:InfraNotFound - EditoastSTDCMErrorInvalidPathItems: + - editoast:scenario:InfraNotFound + EditoastScenarioErrorNotFound: type: object required: - type @@ -5198,21 +5190,21 @@ components: context: type: object required: - - items + - scenario_id properties: - items: - type: array + scenario_id: + type: integer message: type: string status: type: integer enum: - - 400 + - 404 type: type: string enum: - - editoast:stdcm_v2:InvalidPathItems - EditoastSTDCMErrorRollingStockNotFound: + - editoast:scenario:NotFound + EditoastScenarioErrorTimetableNotFound: type: object required: - type @@ -5222,21 +5214,21 @@ components: context: type: object required: - - rolling_stock_id + - timetable_id properties: - rolling_stock_id: + timetable_id: type: integer message: type: string status: type: integer enum: - - 400 + - 404 type: type: string enum: - - editoast:stdcm_v2:RollingStockNotFound - EditoastSTDCMErrorTimetableNotFound: + - editoast:scenario:TimetableNotFound + EditoastSearchApiErrorObjectType: type: object required: - type @@ -5246,21 +5238,21 @@ components: context: type: object required: - - timetable_id + - object_type properties: - timetable_id: - type: integer + object_type: + type: string message: type: string status: type: integer enum: - - 404 + - 400 type: type: string enum: - - editoast:stdcm_v2:TimetableNotFound - EditoastSTDCMErrorTowedRollingStockNotFound: + - editoast:search:ObjectType + EditoastSearchApiErrorSearchEngineError: type: object required: - type @@ -5269,11 +5261,6 @@ components: properties: context: type: object - required: - - towed_rolling_stock_id - properties: - towed_rolling_stock_id: - type: integer message: type: string status: @@ -5283,8 +5270,8 @@ components: type: type: string enum: - - editoast:stdcm_v2:TowedRollingStockNotFound - EditoastScenarioErrorInfraNotFound: + - editoast:search:SearchEngineError + EditoastSpriteErrorsFileNotFound: type: object required: - type @@ -5294,10 +5281,10 @@ components: context: type: object required: - - infra_id + - file properties: - infra_id: - type: integer + file: + type: string message: type: string status: @@ -5307,8 +5294,8 @@ components: type: type: string enum: - - editoast:scenario:InfraNotFound - EditoastScenarioErrorNotFound: + - editoast:sprites:FileNotFound + EditoastSpriteErrorsUnknownSignalingSystem: type: object required: - type @@ -5318,10 +5305,10 @@ components: context: type: object required: - - scenario_id + - signaling_system properties: - scenario_id: - type: integer + signaling_system: + type: string message: type: string status: @@ -5331,8 +5318,8 @@ components: type: type: string enum: - - editoast:scenario:NotFound - EditoastScenarioErrorTimetableNotFound: + - editoast:sprites:UnknownSignalingSystem + EditoastStdcmErrorInfraNotFound: type: object required: - type @@ -5342,21 +5329,21 @@ components: context: type: object required: - - timetable_id + - infra_id properties: - timetable_id: + infra_id: type: integer message: type: string status: type: integer enum: - - 404 + - 400 type: type: string enum: - - editoast:scenario:TimetableNotFound - EditoastSearchApiErrorObjectType: + - editoast:stdcm_v2:InfraNotFound + EditoastStdcmErrorInvalidPathItems: type: object required: - type @@ -5366,10 +5353,10 @@ components: context: type: object required: - - object_type + - items properties: - object_type: - type: string + items: + type: array message: type: string status: @@ -5379,8 +5366,8 @@ components: type: type: string enum: - - editoast:search:ObjectType - EditoastSearchApiErrorSearchEngineError: + - editoast:stdcm_v2:InvalidPathItems + EditoastStdcmErrorRollingStockNotFound: type: object required: - type @@ -5389,6 +5376,11 @@ components: properties: context: type: object + required: + - rolling_stock_id + properties: + rolling_stock_id: + type: integer message: type: string status: @@ -5398,8 +5390,8 @@ components: type: type: string enum: - - editoast:search:SearchEngineError - EditoastSpriteErrorsFileNotFound: + - editoast:stdcm_v2:RollingStockNotFound + EditoastStdcmErrorTimetableNotFound: type: object required: - type @@ -5409,10 +5401,10 @@ components: context: type: object required: - - file + - timetable_id properties: - file: - type: string + timetable_id: + type: integer message: type: string status: @@ -5422,8 +5414,8 @@ components: type: type: string enum: - - editoast:sprites:FileNotFound - EditoastSpriteErrorsUnknownSignalingSystem: + - editoast:stdcm_v2:TimetableNotFound + EditoastStdcmErrorTowedRollingStockNotFound: type: object required: - type @@ -5433,20 +5425,20 @@ components: context: type: object required: - - signaling_system + - towed_rolling_stock_id properties: - signaling_system: - type: string + towed_rolling_stock_id: + type: integer message: type: string status: type: integer enum: - - 404 + - 400 type: type: string enum: - - editoast:sprites:UnknownSignalingSystem + - editoast:stdcm_v2:TowedRollingStockNotFound EditoastStudyErrorNotFound: type: object required: @@ -8226,6 +8218,108 @@ components: type: integer format: int64 minimum: 0 + Request: + type: object + description: An STDCM request + required: + - steps + - rolling_stock_id + - comfort + properties: + comfort: + $ref: '#/components/schemas/Comfort' + electrical_profile_set_id: + type: integer + format: int64 + nullable: true + loading_gauge_type: + allOf: + - $ref: '#/components/schemas/LoadingGaugeType' + nullable: true + margin: + type: string + description: Can be a percentage `X%`, a time in minutes per 100 kilometer `Xmin/100km` + example: + - 5% + - 2min/100km + nullable: true + max_speed: + type: number + format: double + description: Maximum speed of the consist in km/h + nullable: true + maximum_departure_delay: + type: integer + format: int64 + description: |- + By how long we can shift the departure time in milliseconds + Deprecated, first step data should be used instead + nullable: true + minimum: 0 + maximum_run_time: + type: integer + format: int64 + description: |- + Specifies how long the total run time can be in milliseconds + Deprecated, first step data should be used instead + nullable: true + minimum: 0 + rolling_stock_id: + type: integer + format: int64 + speed_limit_tags: + type: string + description: Train categories for speed limits + nullable: true + start_time: + type: string + format: date-time + description: Deprecated, first step arrival time should be used instead + nullable: true + steps: + type: array + items: + $ref: '#/components/schemas/PathfindingItem' + temporary_speed_limit_group_id: + type: integer + format: int64 + nullable: true + time_gap_after: + type: integer + format: int64 + description: |- + Margin after the train passage in milliseconds + + Enforces that the path used by the train should be free and + available at least that many milliseconds after its passage. + minimum: 0 + time_gap_before: + type: integer + format: int64 + description: |- + Margin before the train passage in seconds + + Enforces that the path used by the train should be free and + available at least that many milliseconds before its passage. + minimum: 0 + total_length: + type: number + format: double + description: Total length of the consist in meters + nullable: true + total_mass: + type: number + format: double + description: Total mass of the consist in kg + nullable: true + towed_rolling_stock_id: + type: integer + format: int64 + nullable: true + work_schedule_group_id: + type: integer + format: int64 + nullable: true RjsPowerRestrictionRange: type: object description: A range along the train path where a power restriction is applied. @@ -8734,108 +8828,6 @@ components: type: string zone: type: string - STDCMRequestPayload: - type: object - description: An STDCM request - required: - - steps - - rolling_stock_id - - comfort - properties: - comfort: - $ref: '#/components/schemas/Comfort' - electrical_profile_set_id: - type: integer - format: int64 - nullable: true - loading_gauge_type: - allOf: - - $ref: '#/components/schemas/LoadingGaugeType' - nullable: true - margin: - type: string - description: Can be a percentage `X%`, a time in minutes per 100 kilometer `Xmin/100km` - example: - - 5% - - 2min/100km - nullable: true - max_speed: - type: number - format: double - description: Maximum speed of the consist in km/h - nullable: true - maximum_departure_delay: - type: integer - format: int64 - description: |- - By how long we can shift the departure time in milliseconds - Deprecated, first step data should be used instead - nullable: true - minimum: 0 - maximum_run_time: - type: integer - format: int64 - description: |- - Specifies how long the total run time can be in milliseconds - Deprecated, first step data should be used instead - nullable: true - minimum: 0 - rolling_stock_id: - type: integer - format: int64 - speed_limit_tags: - type: string - description: Train categories for speed limits - nullable: true - start_time: - type: string - format: date-time - description: Deprecated, first step arrival time should be used instead - nullable: true - steps: - type: array - items: - $ref: '#/components/schemas/PathfindingItem' - temporary_speed_limit_group_id: - type: integer - format: int64 - nullable: true - time_gap_after: - type: integer - format: int64 - description: |- - Margin after the train passage in milliseconds - - Enforces that the path used by the train should be free and - available at least that many milliseconds after its passage. - minimum: 0 - time_gap_before: - type: integer - format: int64 - description: |- - Margin before the train passage in seconds - - Enforces that the path used by the train should be free and - available at least that many milliseconds before its passage. - minimum: 0 - total_length: - type: number - format: double - description: Total length of the consist in meters - nullable: true - total_mass: - type: number - format: double - description: Total mass of the consist in kg - nullable: true - towed_rolling_stock_id: - type: integer - format: int64 - nullable: true - work_schedule_group_id: - type: integer - format: int64 - nullable: true Scenario: type: object required: diff --git a/editoast/src/core/conflict_detection.rs b/editoast/src/core/conflict_detection.rs index 6b264a79aa5..c50fdc4ba10 100644 --- a/editoast/src/core/conflict_detection.rs +++ b/editoast/src/core/conflict_detection.rs @@ -41,6 +41,31 @@ pub struct WorkSchedulesRequest { pub start_time: DateTime, pub work_schedule_requirements: HashMap, } +impl WorkSchedulesRequest { + pub fn new( + work_schedules: Vec, + earliest_departure_time: DateTime, + latest_simulation_end: DateTime, + ) -> Option { + if work_schedules.is_empty() { + return None; + } + // Filter the provided work schedules to find those that conflict with the given parameters + // This identifies any work schedules that may overlap with the earliest departure time and latest simulation end. + let work_schedule_requirements = work_schedules + .into_iter() + .filter_map(|ws| { + ws.as_core_work_schedule(earliest_departure_time, latest_simulation_end) + .map(|core_ws| (ws.id, core_ws)) + }) + .collect(); + + Some(Self { + start_time: earliest_departure_time, + work_schedule_requirements, + }) + } +} #[derive(Debug, Deserialize, ToSchema)] pub struct ConflictDetectionResponse { diff --git a/editoast/src/core/simulation.rs b/editoast/src/core/simulation.rs index 84f6567f54a..aa85654202a 100644 --- a/editoast/src/core/simulation.rs +++ b/editoast/src/core/simulation.rs @@ -61,10 +61,10 @@ pub struct PhysicsConsist { /// Mapping of power restriction code to power class #[serde(default)] pub power_restrictions: BTreeMap, - /// The time the train takes before actually using electrical power (in miliseconds). + /// The time the train takes before actually using electrical power (in milliseconds). /// Is null if the train is not electric or the value not specified. pub electrical_power_startup_time: Option, - /// The time it takes to raise this train's pantograph in miliseconds. + /// The time it takes to raise this train's pantograph in milliseconds. /// Is null if the train is not electric or the value not specified. pub raise_pantograph_time: Option, } @@ -476,6 +476,21 @@ impl AsCoreRequest> for SimulationRequest { } } +impl SimulationResponse { + pub fn simulation_run_time(&self) -> Option { + if let SimulationResponse::Success { provisional, .. } = self { + Some( + *provisional + .times + .last() + .expect("core error: empty simulation result"), + ) + } else { + None + } + } +} + #[cfg(test)] mod tests { use editoast_schemas::rolling_stock::RollingResistance; diff --git a/editoast/src/core/stdcm.rs b/editoast/src/core/stdcm.rs index f796e349a0d..924ef990568 100644 --- a/editoast/src/core/stdcm.rs +++ b/editoast/src/core/stdcm.rs @@ -11,14 +11,13 @@ use serde::Deserialize; use serde::Serialize; use utoipa::ToSchema; -use super::conflict_detection::Conflict; use super::conflict_detection::TrainRequirements; use super::pathfinding::PathfindingResultSuccess; use super::pathfinding::TrackRange; use super::simulation::PhysicsConsist; use super::simulation::SimulationResponse; -use crate::core::{AsCoreRequest, Json}; -use crate::views::path::pathfinding::PathfindingResult; +use crate::core::AsCoreRequest; +use crate::core::Json; #[derive(Debug, Serialize)] pub struct STDCMRequest { @@ -115,28 +114,24 @@ pub struct UndirectedTrackRange { pub end: u64, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, ToSchema)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(tag = "status", rename_all = "snake_case")] // We accepted the difference of memory size taken by variants // Since there is only on success and others are error cases #[allow(clippy::large_enum_variant)] -pub enum STDCMResponse { +pub enum Response { Success { simulation: SimulationResponse, path: PathfindingResultSuccess, departure_time: DateTime, }, PathNotFound, - Conflicts { - pathfinding_result: PathfindingResult, - conflicts: Vec, - }, PreprocessingSimulationError { error: SimulationResponse, }, } -impl AsCoreRequest> for STDCMRequest { +impl AsCoreRequest> for STDCMRequest { const METHOD: reqwest::Method = reqwest::Method::POST; const URL_PATH: &'static str = "/v2/stdcm"; diff --git a/editoast/src/models/work_schedules.rs b/editoast/src/models/work_schedules.rs index 87beb1064e6..80da485fc51 100644 --- a/editoast/src/models/work_schedules.rs +++ b/editoast/src/models/work_schedules.rs @@ -1,3 +1,5 @@ +use std::cmp::max; + use chrono::DateTime; use chrono::Utc; use editoast_derive::Model; @@ -8,6 +10,8 @@ use serde::Deserialize; use serde::Serialize; use utoipa::ToSchema; +use crate::core::stdcm::UndirectedTrackRange; + #[derive(Debug, Clone, Model)] #[model(table = editoast_models::tables::work_schedule_group)] #[model(gen(ops = crd, batch_ops = c, list))] @@ -39,3 +43,39 @@ pub struct WorkSchedule { pub work_schedule_type: WorkScheduleType, pub work_schedule_group_id: i64, } + +impl WorkSchedule { + pub fn as_core_work_schedule( + &self, + earliest_departure_time: DateTime, + latest_simulation_end: DateTime, + ) -> Option { + let search_window_duration = + (latest_simulation_end - earliest_departure_time).num_milliseconds() as u64; + + let start_time = elapsed_time_since_ms(&self.start_date_time, &earliest_departure_time); + let end_time = elapsed_time_since_ms(&self.end_date_time, &earliest_departure_time); + + if end_time == 0 || start_time >= search_window_duration { + return None; + } + + Some(crate::core::stdcm::WorkSchedule { + start_time, + end_time, + track_ranges: self + .track_ranges + .iter() + .map(|track| UndirectedTrackRange { + track_section: track.track.to_string(), + begin: (track.begin * 1000.0) as u64, + end: (track.end * 1000.0) as u64, + }) + .collect(), + }) + } +} + +fn elapsed_time_since_ms(time: &DateTime, since: &DateTime) -> u64 { + max(0, (*time - since).num_milliseconds()) as u64 +} diff --git a/editoast/src/views/timetable/stdcm.rs b/editoast/src/views/timetable/stdcm.rs index ff1bea6b7d7..2dd0c657208 100644 --- a/editoast/src/views/timetable/stdcm.rs +++ b/editoast/src/views/timetable/stdcm.rs @@ -1,54 +1,46 @@ +mod failure_handler; +mod request; + use axum::extract::Json; use axum::extract::Path; use axum::extract::Query; use axum::extract::State; use axum::Extension; -use chrono::{DateTime, Duration, Utc}; +use chrono::Utc; +use chrono::{DateTime, Duration}; use editoast_authz::BuiltinRole; use editoast_derive::EditoastError; -use editoast_models::DbConnection; use editoast_models::DbConnectionPoolV2; use editoast_schemas::primitives::PositiveDuration; -use editoast_schemas::rolling_stock::LoadingGaugeType; -use editoast_schemas::train_schedule::PathItemLocation; +use editoast_schemas::train_schedule::MarginValue; +use editoast_schemas::train_schedule::Margins; use editoast_schemas::train_schedule::ReceptionSignal; -use editoast_schemas::train_schedule::{Comfort, Margins, PathItem}; -use editoast_schemas::train_schedule::{MarginValue, ScheduleItem}; -use itertools::Itertools; +use editoast_schemas::train_schedule::ScheduleItem; +use failure_handler::SimulationFailureHandler; +use request::convert_steps; +use request::Request; use serde::Deserialize; use serde::Serialize; -use std::cmp::max; use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; use utoipa::IntoParams; use utoipa::ToSchema; -use super::SelectionSettings; -use crate::core::conflict_detection::ConflictDetectionRequest; +use crate::core::conflict_detection::Conflict; use crate::core::conflict_detection::TrainRequirements; -use crate::core::conflict_detection::WorkSchedulesRequest; use crate::core::pathfinding::InvalidPathItem; -use crate::core::pathfinding::PathfindingInputError; +use crate::core::pathfinding::PathfindingResultSuccess; use crate::core::simulation::PhysicsConsistParameters; use crate::core::simulation::{RoutingRequirement, SimulationResponse, SpacingRequirement}; -use crate::core::stdcm::STDCMPathItem; use crate::core::stdcm::STDCMRequest; -use crate::core::stdcm::STDCMResponse; -use crate::core::stdcm::STDCMStepTimingData; -use crate::core::stdcm::UndirectedTrackRange; use crate::core::AsCoreRequest; use crate::core::CoreClient; use crate::error::Result; -use crate::models::temporary_speed_limits::TemporarySpeedLimit; use crate::models::timetable::TimetableWithTrains; -use crate::models::towed_rolling_stock::TowedRollingStockModel; use crate::models::train_schedule::TrainSchedule; -use crate::models::work_schedules::WorkSchedule; +use crate::models::Infra; use crate::models::RollingStockModel; -use crate::models::{Infra, List}; -use crate::views::path::path_item_cache::PathItemCache; -use crate::views::path::pathfinding::PathfindingFailure; use crate::views::path::pathfinding::PathfindingResult; use crate::views::train_schedule::train_simulation; use crate::views::train_schedule::train_simulation_batch; @@ -59,19 +51,37 @@ use crate::Retrieve; use crate::RetrieveBatch; use crate::ValkeyClient; +editoast_common::schemas! { + request::schemas(), +} + crate::routes! { "/stdcm" => stdcm, } -editoast_common::schemas! { - STDCMRequestPayload, - PathfindingItem, - StepTimingData, +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, ToSchema)] +#[serde(tag = "status", rename_all = "snake_case")] +// We accepted the difference of memory size taken by variants +// Since there is only on success and others are error cases +#[allow(clippy::large_enum_variant)] +enum StdcmResponse { + Success { + simulation: SimulationResponse, + path: PathfindingResultSuccess, + departure_time: DateTime, + }, + Conflicts { + pathfinding_result: PathfindingResult, + conflicts: Vec, + }, + PreprocessingSimulationError { + error: SimulationResponse, + }, } #[derive(Debug, Error, EditoastError, Serialize)] #[editoast_error(base_id = "stdcm_v2")] -enum STDCMError { +enum StdcmError { #[error("Infrastrcture {infra_id} does not exist")] InfraNotFound { infra_id: i64 }, #[error("Timetable {timetable_id} does not exist")] @@ -85,72 +95,6 @@ enum STDCMError { InvalidPathItems { items: Vec }, } -/// An STDCM request -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] -pub struct STDCMRequestPayload { - /// Deprecated, first step arrival time should be used instead - start_time: Option>, - steps: Vec, - rolling_stock_id: i64, - towed_rolling_stock_id: Option, - electrical_profile_set_id: Option, - work_schedule_group_id: Option, - temporary_speed_limit_group_id: Option, - comfort: Comfort, - /// By how long we can shift the departure time in milliseconds - /// Deprecated, first step data should be used instead - maximum_departure_delay: Option, - /// Specifies how long the total run time can be in milliseconds - /// Deprecated, first step data should be used instead - maximum_run_time: Option, - /// Train categories for speed limits - // TODO: rename the field and its description - speed_limit_tags: Option, - /// Margin before the train passage in seconds - /// - /// Enforces that the path used by the train should be free and - /// available at least that many milliseconds before its passage. - #[serde(default)] - time_gap_before: u64, - /// Margin after the train passage in milliseconds - /// - /// Enforces that the path used by the train should be free and - /// available at least that many milliseconds after its passage. - #[serde(default)] - time_gap_after: u64, - /// Can be a percentage `X%`, a time in minutes per 100 kilometer `Xmin/100km` - #[serde(default)] - #[schema(value_type = Option, example = json!(["5%", "2min/100km"]))] - margin: Option, - /// Total mass of the consist in kg - total_mass: Option, - /// Total length of the consist in meters - total_length: Option, - /// Maximum speed of the consist in km/h - max_speed: Option, - loading_gauge_type: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)] -struct PathfindingItem { - /// The stop duration in milliseconds, None if the train does not stop. - duration: Option, - /// The associated location - location: PathItemLocation, - /// Time at which the train should arrive at the location, if specified - timing_data: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)] -struct StepTimingData { - /// Time at which the train should arrive at the location - arrival_time: DateTime, - /// The train may arrive up to this duration before the expected arrival time - arrival_time_tolerance_before: u64, - /// The train may arrive up to this duration after the expected arrival time - arrival_time_tolerance_after: u64, -} - #[derive(Debug, Default, Clone, Serialize, Deserialize, IntoParams, ToSchema)] struct InfraIdQueryParam { infra: i64, @@ -168,12 +112,12 @@ struct InfraIdQueryParam { #[utoipa::path( post, path = "", tag = "stdcm", - request_body = inline(STDCMRequestPayload), + request_body = inline(Request), params(("infra" = i64, Query, description = "The infra id"), ("id" = i64, Path, description = "timetable_id"), ), responses( - (status = 201, body = inline(STDCMResponse), description = "The simulation result"), + (status = 201, body = inline(StdcmResponse), description = "The simulation result"), ) )] async fn stdcm( @@ -181,8 +125,8 @@ async fn stdcm( Extension(auth): AuthenticationExt, Path(id): Path, Query(query): Query, - Json(stdcm_request): Json, -) -> Result> { + Json(stdcm_request): Json, +) -> Result> { let authorized = auth .check_roles([BuiltinRole::Stdcm].into()) .await @@ -201,56 +145,26 @@ async fn stdcm( // 1. Retrieve Timetable / Infra / Trains / Simulation / Rolling Stock let timetable_trains = TimetableWithTrains::retrieve_or_fail(conn, timetable_id, || { - STDCMError::TimetableNotFound { timetable_id } + StdcmError::TimetableNotFound { timetable_id } }) .await?; let infra = - Infra::retrieve_or_fail(conn, infra_id, || STDCMError::InfraNotFound { infra_id }).await?; + Infra::retrieve_or_fail(conn, infra_id, || StdcmError::InfraNotFound { infra_id }).await?; let (train_schedules, _): (Vec<_>, _) = TrainSchedule::retrieve_batch(conn, timetable_trains.train_ids.clone()).await?; let rolling_stock = RollingStockModel::retrieve_or_fail(conn, stdcm_request.rolling_stock_id, || { - STDCMError::RollingStockNotFound { + StdcmError::RollingStockNotFound { rolling_stock_id: stdcm_request.rolling_stock_id, } }) .await?; - let towed_rolling_stock = - if let Some(towed_rolling_stock_id) = stdcm_request.towed_rolling_stock_id { - let towed_rolling_stock = - TowedRollingStockModel::retrieve_or_fail(conn, towed_rolling_stock_id, || { - STDCMError::TowedRollingStockNotFound { - towed_rolling_stock_id, - } - }) - .await?; - Some(towed_rolling_stock) - } else { - None - }; - - let simulations = train_simulation_batch( - conn, - valkey_client.clone(), - core_client.clone(), - &train_schedules, - &infra, - stdcm_request.electrical_profile_set_id, - ) - .await?; - - // 2. Compute the earliest start time, maximum running time and maximum departure delay - // Simulation time without stop duration - let ( - simulation_run_time, - virtual_train_schedule, - virtual_train_sim_result, - virtual_train_pathfinding_result, - ) = simulate_train_run( + // 2. Compute the earliest start time and maximum departure delay + let virtual_train_run = VirtualTrainRun::simulate( db_pool.clone(), valkey_client.clone(), core_client.clone(), @@ -260,31 +174,31 @@ async fn stdcm( timetable_id, ) .await?; - let simulation_run_time = match simulation_run_time { - SimulationTimeResult::SimulationTime { value } => value, - SimulationTimeResult::Error { error } => { - return Ok(Json(STDCMResponse::PreprocessingSimulationError { - error: *error, - })) - } - }; - let earliest_step_tolerance_window = get_earliest_step_tolerance_window(&stdcm_request); - let maximum_departure_delay = get_maximum_departure_delay( - &stdcm_request, - simulation_run_time, - earliest_step_tolerance_window, - ); - // Maximum duration between train departure and arrival, including all stops - let maximum_run_time = stdcm_request - .maximum_run_time - .unwrap_or(2 * simulation_run_time + get_total_stop_time(&stdcm_request)); + // Only the success variant of the simulation response contains the simulation run time. + let Some(simulation_run_time) = virtual_train_run.simulation.simulation_run_time() else { + return Ok(Json(StdcmResponse::PreprocessingSimulationError { + error: virtual_train_run.simulation, + })); + }; - let earliest_departure_time = get_earliest_departure_time(&stdcm_request, maximum_run_time); - let latest_simulation_end = earliest_departure_time - + Duration::milliseconds((maximum_run_time + earliest_step_tolerance_window) as i64); + let earliest_departure_time = stdcm_request.get_earliest_departure_time(simulation_run_time); + let latest_simulation_end = stdcm_request.get_latest_simulation_end(simulation_run_time); // 3. Get scheduled train requirements + let simulations: Vec<_> = train_simulation_batch( + conn, + valkey_client.clone(), + core_client.clone(), + &train_schedules, + &infra, + stdcm_request.electrical_profile_set_id, + ) + .await? + .into_iter() + .map(|(sim, _)| sim) + .collect(); + let trains_requirements = build_train_requirements( train_schedules.clone(), simulations.clone(), @@ -292,34 +206,10 @@ async fn stdcm( latest_simulation_end, ); - // 4. Parse stdcm path items - let path_items = parse_stdcm_steps(conn, &stdcm_request, &infra).await?; - - // 5. Get applicable temporary speed limits - let temporary_speed_limits = match stdcm_request.temporary_speed_limit_group_id { - Some(group_id) => { - build_temporary_speed_limits( - conn, - earliest_departure_time, - latest_simulation_end, - group_id, - ) - .await? - } - None => vec![], - }; - - // 6. Retrieve work schedules - let work_schedules = match stdcm_request.work_schedule_group_id { - Some(work_schedule_group_id) => { - let selection_setting = SelectionSettings::new() - .filter(move || WorkSchedule::WORK_SCHEDULE_GROUP_ID.eq(work_schedule_group_id)); - WorkSchedule::list(conn, selection_setting).await? - } - None => vec![], - }; + // 4. Retrieve work schedules + let work_schedules = stdcm_request.get_work_schedules(conn).await?; - // 7. Build STDCM request + // 5. Build STDCM request let stdcm_request = STDCMRequest { infra: infra.id, expected_version: infra.version.clone(), @@ -327,143 +217,83 @@ async fn stdcm( rolling_stock_supported_signaling_systems: rolling_stock .supported_signaling_systems .clone(), - comfort: stdcm_request.comfort, - path_items, - start_time: earliest_departure_time, - trains_requirements: trains_requirements.clone(), - maximum_departure_delay, - maximum_run_time, - speed_limit_tag: stdcm_request.speed_limit_tags, - time_gap_before: stdcm_request.time_gap_before, - time_gap_after: stdcm_request.time_gap_after, - margin: stdcm_request.margin, - time_step: Some(2000), - work_schedules: filter_stdcm_work_schedules( - &work_schedules, - earliest_departure_time, - latest_simulation_end, - ), - temporary_speed_limits, rolling_stock: PhysicsConsistParameters { max_speed: stdcm_request.max_speed, total_length: stdcm_request.total_length, total_mass: stdcm_request.total_mass, - towed_rolling_stock: towed_rolling_stock.map(From::from), + towed_rolling_stock: stdcm_request + .get_towed_rolling_stock(conn) + .await? + .map(From::from), traction_engine: rolling_stock.into(), } .into(), + temporary_speed_limits: stdcm_request + .get_temporary_speed_limits(conn, simulation_run_time) + .await?, + comfort: stdcm_request.comfort, + path_items: stdcm_request.get_stdcm_path_items(conn, infra_id).await?, + start_time: earliest_departure_time, + trains_requirements, + maximum_departure_delay: stdcm_request.get_maximum_departure_delay(simulation_run_time), + maximum_run_time: stdcm_request.get_maximum_run_time(simulation_run_time), + speed_limit_tag: stdcm_request.speed_limit_tags, + time_gap_before: stdcm_request.time_gap_before, + time_gap_after: stdcm_request.time_gap_after, + margin: stdcm_request.margin, + time_step: Some(2000), + work_schedules: work_schedules + .iter() + .filter_map(|ws| { + ws.as_core_work_schedule(earliest_departure_time, latest_simulation_end) + }) + .collect(), }; let stdcm_response = stdcm_request.fetch(core_client.as_ref()).await?; - // 8. Handle PathNotFound response of STDCM - if let STDCMResponse::PathNotFound = stdcm_response { - let stdcm_response = handle_path_not_found( - virtual_train_schedule, - train_schedules, - simulations, - virtual_train_sim_result, - virtual_train_pathfinding_result, - earliest_departure_time, - latest_simulation_end, - &work_schedules, - infra_id, - infra.version, - core_client, - ) - .await?; - - return Ok(Json(stdcm_response)); + // 6. Handle STDCM Core Response + match stdcm_response { + crate::core::stdcm::Response::Success { + simulation, + path, + departure_time, + } => Ok(Json(StdcmResponse::Success { + simulation, + path, + departure_time, + })), + crate::core::stdcm::Response::PreprocessingSimulationError { error } => { + Ok(Json(StdcmResponse::PreprocessingSimulationError { error })) + } + crate::core::stdcm::Response::PathNotFound => { + let simulation_failure_handler = SimulationFailureHandler { + core_client, + infra_id, + infra_version: infra.version, + train_schedules, + simulations, + work_schedules, + virtual_train_run, + earliest_departure_time, + latest_simulation_end, + }; + let stdcm_response = simulation_failure_handler.compute_conflicts().await?; + Ok(Json(stdcm_response)) + } } - - Ok(Json(stdcm_response)) -} - -#[allow(clippy::too_many_arguments)] -async fn handle_path_not_found( - virtual_train_schedule: TrainSchedule, - train_schedules: Vec, - simulations: Vec<(SimulationResponse, PathfindingResult)>, - virtual_train_sim_result: SimulationResponse, - virtual_train_pathfinding_result: PathfindingResult, - earliest_departure_time: DateTime, - latest_simulation_end: DateTime, - work_schedules: &[WorkSchedule], - infra_id: i64, - infra_version: String, - core_client: Arc, -) -> Result { - let virtual_train_id = virtual_train_schedule.id; - - // Combine the original train schedules with the virtual train schedule. - let train_schedules = [train_schedules, vec![virtual_train_schedule]].concat(); - - // Combine the original simulations with the virtual train's simulation results. - let simulations = [ - simulations, - vec![( - virtual_train_sim_result, - virtual_train_pathfinding_result.clone(), - )], - ] - .concat(); - - // Build train requirements based on the combined train schedules and simulations - // This prepares the data structure required for conflict detection. - let trains_requirements = build_train_requirements( - train_schedules, - simulations, - earliest_departure_time, - latest_simulation_end, - ); - - // Filter the provided work schedules to find those that conflict with the given parameters - // This identifies any work schedules that may overlap with the earliest departure time and maximum run time. - let conflict_work_schedules = make_work_schedules_request( - work_schedules, - earliest_departure_time, - latest_simulation_end, - ); - - // Prepare the conflict detection request. - let conflict_detection_request = ConflictDetectionRequest { - infra: infra_id, - expected_version: infra_version, - trains_requirements, - work_schedules: conflict_work_schedules, - }; - - // Send the conflict detection request and await the response. - let conflict_detection_response = conflict_detection_request.fetch(&core_client).await?; - - // Filter the conflicts to find those specifically related to the virtual train. - let conflicts: Vec<_> = conflict_detection_response - .conflicts - .into_iter() - .filter(|conflict| conflict.train_ids.contains(&virtual_train_id)) - .map(|mut conflict| { - conflict.train_ids.retain(|id| id != &virtual_train_id); - conflict - }) - .collect(); - - // Return the conflicts found along with the pathfinding result for the virtual train. - Ok(STDCMResponse::Conflicts { - pathfinding_result: virtual_train_pathfinding_result, - conflicts, - }) } /// Build the list of scheduled train requirements, only including requirements /// that overlap with the possible simulation times. fn build_train_requirements( train_schedules: Vec, - simulations: Vec<(SimulationResponse, PathfindingResult)>, + simulation_responses: Vec, departure_time: DateTime, latest_simulation_end: DateTime, ) -> HashMap { let mut trains_requirements = HashMap::new(); - for (train, (sim, _)) in train_schedules.iter().zip(simulations) { + for (train, sim) in train_schedules.iter().zip(simulation_responses) { let final_output = match sim { SimulationResponse::Success { final_output, .. } => final_output, _ => continue, @@ -538,157 +368,69 @@ fn is_resource_in_range( abs_resource_start_time <= latest_sim_time && abs_resource_end_time >= earliest_sim_time } -// Returns the maximum departure delay for the train. -fn get_maximum_departure_delay( - data: &STDCMRequestPayload, - simulation_run_time: u64, - earliest_step_tolerance_window: u64, -) -> u64 { - data.maximum_departure_delay - .unwrap_or(simulation_run_time + earliest_step_tolerance_window) -} - -/// Returns the earliest time at which the train may start -fn get_earliest_departure_time(data: &STDCMRequestPayload, maximum_run_time: u64) -> DateTime { - // Prioritize: start time, or first step time, or (first specified time - max run time) - data.start_time.unwrap_or( - data.steps - .first() - .and_then(|step| step.timing_data.clone()) - .and_then(|data| { - Option::from( - data.arrival_time - - Duration::milliseconds(data.arrival_time_tolerance_before as i64), - ) - }) - .unwrap_or( - get_earliest_step_time(data) - Duration::milliseconds(maximum_run_time as i64), - ), - ) -} - -/// Returns the earliest time that has been set on any step -fn get_earliest_step_time(data: &STDCMRequestPayload) -> DateTime { - // Get the earliest time that has been specified for any step - data.start_time - .or_else(|| { - data.steps - .iter() - .flat_map(|step| step.timing_data.iter()) - .map(|data| { - data.arrival_time - - Duration::milliseconds(data.arrival_time_tolerance_before as i64) - }) - .next() - }) - .expect("No time specified for stdcm request") +struct VirtualTrainRun { + train_schedule: TrainSchedule, + simulation: SimulationResponse, + pathfinding: PathfindingResult, } -/// Returns the earliest tolerance window that has been set on any step -fn get_earliest_step_tolerance_window(data: &STDCMRequestPayload) -> u64 { - // Get the earliest time window that has been specified for any step, if maximum_run_time is not none - data.steps - .iter() - .flat_map(|step| step.timing_data.iter()) - .map(|data| data.arrival_time_tolerance_before + data.arrival_time_tolerance_after) - .next() - .unwrap_or(0) -} - -/// Returns a `Result` containing: -/// * `SimulationTimeResult` - The result of the simulation time calculation. -/// * `TrainSchedule` - The generated train schedule based on the provided data. -/// * `SimulationResponse` - Simulation response. -/// * `PathfindingResult` - Pathfinding result. -async fn simulate_train_run( - db_pool: Arc, - valkey_client: Arc, - core_client: Arc, - data: &STDCMRequestPayload, - infra: &Infra, - rolling_stock: &RollingStockModel, - timetable_id: i64, -) -> Result<( - SimulationTimeResult, - TrainSchedule, - SimulationResponse, - PathfindingResult, -)> { - // Doesn't matter for now, but eventually it will affect tmp speed limits - let approx_start_time = get_earliest_step_time(data); - - let path = convert_steps(&data.steps); - let last_step = path.last().expect("empty step list"); - - let train_schedule = TrainSchedule { - id: 0, - train_name: "".to_string(), - labels: vec![], - rolling_stock_name: rolling_stock.name.clone(), - timetable_id, - start_time: approx_start_time, - schedule: vec![ScheduleItem { - // Make the train stop at the end - at: last_step.id.clone(), - arrival: None, - stop_for: Some(PositiveDuration::try_from(Duration::zero()).unwrap()), - reception_signal: ReceptionSignal::Open, - locked: false, - }], - margins: build_single_margin(data.margin), - initial_speed: 0.0, - comfort: data.comfort, - path, - constraint_distribution: Default::default(), - speed_limit_tag: data.speed_limit_tags.clone(), - power_restrictions: vec![], - options: Default::default(), - }; - - let (sim_result, pathfinding_result) = train_simulation( - &mut db_pool.get().await?, - valkey_client, - core_client, - train_schedule.clone(), - infra, - None, - ) - .await?; - - let simulation_run_time = match sim_result.clone() { - SimulationResponse::Success { provisional, .. } => SimulationTimeResult::SimulationTime { - value: *provisional.times.last().expect("empty simulation result"), - }, - err => SimulationTimeResult::Error { - error: Box::from(err), - }, - }; - Ok(( - simulation_run_time, - train_schedule, - sim_result, - pathfinding_result, - )) -} +impl VirtualTrainRun { + async fn simulate( + db_pool: Arc, + valkey_client: Arc, + core_client: Arc, + stdcm_request: &Request, + infra: &Infra, + rolling_stock: &RollingStockModel, + timetable_id: i64, + ) -> Result { + // Doesn't matter for now, but eventually it will affect tmp speed limits + let approx_start_time = stdcm_request.get_earliest_step_time(); + + let path = convert_steps(&stdcm_request.steps); + let last_step = path.last().expect("empty step list"); + + let train_schedule = TrainSchedule { + id: 0, + train_name: "".to_string(), + labels: vec![], + rolling_stock_name: rolling_stock.name.clone(), + timetable_id, + start_time: approx_start_time, + schedule: vec![ScheduleItem { + // Make the train stop at the end + at: last_step.id.clone(), + arrival: None, + stop_for: Some(PositiveDuration::try_from(Duration::zero()).unwrap()), + reception_signal: ReceptionSignal::Open, + locked: false, + }], + margins: build_single_margin(stdcm_request.margin), + initial_speed: 0.0, + comfort: stdcm_request.comfort, + path, + constraint_distribution: Default::default(), + speed_limit_tag: stdcm_request.speed_limit_tags.clone(), + power_restrictions: vec![], + options: Default::default(), + }; -/// Returns the request's total stop time -fn get_total_stop_time(data: &STDCMRequestPayload) -> u64 { - data.steps - .iter() - .map(|step: &PathfindingItem| step.duration.unwrap_or_default()) - .sum() -} + let (simulation, pathfinding) = train_simulation( + &mut db_pool.get().await?, + valkey_client, + core_client, + train_schedule.clone(), + infra, + None, + ) + .await?; -/// Convert the list of pathfinding items into a list of path item -fn convert_steps(steps: &[PathfindingItem]) -> Vec { - steps - .iter() - .map(|step| PathItem { - id: Default::default(), - deleted: false, - location: step.location.clone(), + Ok(Self { + train_schedule, + simulation, + pathfinding, }) - .collect() + } } /// Build a margins object with one margin value covering the entire range @@ -705,131 +447,6 @@ fn build_single_margin(margin: Option) -> Margins { } } -fn map_to_core_work_schedule( - ws: &WorkSchedule, - start_time: DateTime, -) -> crate::core::stdcm::WorkSchedule { - crate::core::stdcm::WorkSchedule { - start_time: elapsed_since_time_ms(&ws.start_date_time, &start_time), - end_time: elapsed_since_time_ms(&ws.end_date_time, &start_time), - track_ranges: ws - .track_ranges - .iter() - .map(|track| UndirectedTrackRange { - track_section: track.track.to_string(), - begin: (track.begin * 1000.0) as u64, - end: (track.end * 1000.0) as u64, - }) - .collect(), - } -} - -fn filter_stdcm_work_schedules( - work_schedules: &[WorkSchedule], - start_time: DateTime, - latest_simulation_end: DateTime, -) -> Vec { - let search_window_duration = (latest_simulation_end - start_time).num_milliseconds() as u64; - work_schedules - .iter() - .map(|ws| map_to_core_work_schedule(ws, start_time)) - .filter(|ws| ws.end_time > 0 && ws.start_time < search_window_duration) - .collect() -} - -fn make_work_schedules_request( - work_schedules: &[WorkSchedule], - start_time: DateTime, - latest_simulation_end: DateTime, -) -> Option { - if work_schedules.is_empty() { - return None; - } - let search_window_duration = (latest_simulation_end - start_time).num_milliseconds() as u64; - - let work_schedule_requirements = work_schedules - .iter() - .map(|ws| (ws.id, map_to_core_work_schedule(ws, start_time))) - .filter(|(_, ws)| ws.end_time > 0 && ws.start_time < search_window_duration) - .collect(); - - Some(WorkSchedulesRequest { - start_time, - work_schedule_requirements, - }) -} - -/// Return the list of speed limits that are active at any point in a given time range -async fn build_temporary_speed_limits( - conn: &mut DbConnection, - start_date_time: DateTime, - end_date_time: DateTime, - temporary_speed_limit_group_id: i64, -) -> Result> { - if end_date_time <= start_date_time { - return Ok(Vec::new()); - } - let selection_settings: SelectionSettings = SelectionSettings::new() - .filter(move || { - TemporarySpeedLimit::TEMPORARY_SPEED_LIMIT_GROUP_ID.eq(temporary_speed_limit_group_id) - }); - let applicable_speed_limits = TemporarySpeedLimit::list(conn, selection_settings) - .await? - .into_iter() - .filter(|speed_limit| { - !(end_date_time <= speed_limit.start_date_time.and_utc() - || speed_limit.end_date_time.and_utc() <= start_date_time) - }) - .map_into() - .collect(); - Ok(applicable_speed_limits) -} - -fn elapsed_since_time_ms(time: &DateTime, zero: &DateTime) -> u64 { - max(0, (*time - zero).num_milliseconds()) as u64 -} - -/// Create steps from track_map and waypoints -async fn parse_stdcm_steps( - conn: &mut DbConnection, - data: &STDCMRequestPayload, - infra: &Infra, -) -> Result> { - let locations: Vec<_> = data.steps.iter().map(|item| &item.location).collect(); - - let path_item_cache = PathItemCache::load(conn, infra.id, &locations).await?; - let track_offsets = path_item_cache - .extract_location_from_path_items(&locations) - .map_err(|path_res| match path_res { - PathfindingResult::Failure(PathfindingFailure::PathfindingInputError( - PathfindingInputError::InvalidPathItems { items }, - )) => STDCMError::InvalidPathItems { items }, - _ => panic!("Unexpected pathfinding result"), - })?; - - Ok(track_offsets - .iter() - .zip(&data.steps) - .map(|(track_offset, path_item)| STDCMPathItem { - stop_duration: path_item.duration, - locations: track_offset.to_vec(), - step_timing_data: path_item.timing_data.as_ref().map(|timing_data| { - STDCMStepTimingData { - arrival_time: timing_data.arrival_time, - arrival_time_tolerance_before: timing_data.arrival_time_tolerance_before, - arrival_time_tolerance_after: timing_data.arrival_time_tolerance_after, - } - }), - }) - .collect()) -} - -#[derive(Debug)] -enum SimulationTimeResult { - SimulationTime { value: u64 }, - Error { error: Box }, -} - #[cfg(test)] mod tests { use axum::http::StatusCode; @@ -852,7 +469,6 @@ mod tests { use crate::core::simulation::ReportTrain; use crate::core::simulation::SimulationResponse; use crate::core::simulation::SpeedLimitProperties; - use crate::core::stdcm::STDCMResponse; use crate::models::fixtures::create_fast_rolling_stock; use crate::models::fixtures::create_simple_rolling_stock; use crate::models::fixtures::create_small_infra; @@ -965,7 +581,7 @@ mod tests { } #[test] - fn new_physics_rolling_stock_keeps_the_bigest_available_startup_acceleration() { + fn new_physics_rolling_stock_keeps_the_biggest_available_startup_acceleration() { let mut simulation_parameters = PhysicsConsistParameters { max_speed: None, total_length: None, @@ -1136,13 +752,13 @@ mod tests { .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) .json(&stdcm_payload(rolling_stock.id)); - let stdcm_response: STDCMResponse = + let stdcm_response: StdcmResponse = app.fetch(request).assert_status(StatusCode::OK).json_into(); if let PathfindingResult::Success(path) = pathfinding_result_success() { assert_eq!( stdcm_response, - STDCMResponse::Success { + StdcmResponse::Success { simulation: simulation_response(), path, departure_time: DateTime::from_str("2024-01-02T00:00:00Z") @@ -1182,7 +798,7 @@ mod tests { .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) .json(&stdcm_payload(rolling_stock.id)); - let stdcm_response: STDCMResponse = + let stdcm_response: StdcmResponse = app.fetch(request).assert_status(StatusCode::OK).json_into(); let mut conflict = conflict_data(); @@ -1190,7 +806,7 @@ mod tests { assert_eq!( stdcm_response, - STDCMResponse::Conflicts { + StdcmResponse::Conflicts { pathfinding_result: pathfinding_result_success(), conflicts: vec![conflict], } @@ -1216,6 +832,8 @@ mod tests { #[case] filtered_out: bool, ) { // GIVEN + + use crate::models::work_schedules::WorkSchedule; let work_schedules = [WorkSchedule { id: rand::random::(), start_date_time: DateTime::parse_from_rfc3339(ws_start_time) @@ -1232,8 +850,10 @@ mod tests { .to_utc(); // WHEN - let filtered = - filter_stdcm_work_schedules(&work_schedules, start_time, latest_simulation_end); + let filtered: Vec<_> = work_schedules + .iter() + .filter_map(|ws| ws.as_core_work_schedule(start_time, latest_simulation_end)) + .collect(); // THEN assert!(filtered.is_empty() == filtered_out); diff --git a/editoast/src/views/timetable/stdcm/failure_handler.rs b/editoast/src/views/timetable/stdcm/failure_handler.rs new file mode 100644 index 00000000000..486d946d480 --- /dev/null +++ b/editoast/src/views/timetable/stdcm/failure_handler.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; + +use chrono::DateTime; +use chrono::Utc; + +use crate::core::conflict_detection::ConflictDetectionRequest; +use crate::core::conflict_detection::WorkSchedulesRequest; +use crate::core::simulation::SimulationResponse; +use crate::core::AsCoreRequest; +use crate::core::CoreClient; +use crate::error::Result; +use crate::models::train_schedule::TrainSchedule; +use crate::models::work_schedules::WorkSchedule; +use crate::views::timetable::stdcm::StdcmResponse; + +use super::build_train_requirements; +use super::VirtualTrainRun; + +/// `SimulationFailureHandler` is used when a simulation failure occurs, +/// particularly when a train's path cannot be found. It helps detect +/// conflicts between the `virtual_train` and existing train schedules and simulations. +/// `virtual_train` is a simulated train created to detect conflicts +/// when a real train’s path cannot be found during the simulation. +pub(super) struct SimulationFailureHandler { + pub(super) core_client: Arc, + pub(super) infra_id: i64, + pub(super) infra_version: String, + pub(super) train_schedules: Vec, + pub(super) simulations: Vec, + pub(super) work_schedules: Vec, + pub(super) virtual_train_run: VirtualTrainRun, + pub(super) earliest_departure_time: DateTime, + pub(super) latest_simulation_end: DateTime, +} + +impl SimulationFailureHandler { + pub(super) async fn compute_conflicts(self) -> Result { + let Self { + mut train_schedules, + mut simulations, + work_schedules, + virtual_train_run: + VirtualTrainRun { + train_schedule, + simulation, + pathfinding, + }, + infra_id, + infra_version, + earliest_departure_time, + latest_simulation_end, + .. + } = self; + let virtual_train_id = train_schedule.id; + let work_schedules = WorkSchedulesRequest::new( + work_schedules, + earliest_departure_time, + latest_simulation_end, + ); + + // Combine the original train schedules with the virtual train schedule. + train_schedules.push(train_schedule); + + // Combine the original simulations with the virtual train's simulation results. + simulations.push(simulation); + + // Build train requirements based on the combined train schedules and simulations + // This prepares the data structure required for conflict detection. + let trains_requirements = build_train_requirements( + train_schedules, + simulations, + earliest_departure_time, + latest_simulation_end, + ); + + // Prepare the conflict detection request. + let conflict_detection_request = ConflictDetectionRequest { + infra: infra_id, + expected_version: infra_version, + trains_requirements, + work_schedules, + }; + + // Send the conflict detection request and await the response. + let conflict_detection_response = + conflict_detection_request.fetch(&self.core_client).await?; + + // Filter the conflicts to find those specifically related to the virtual train. + let conflicts: Vec<_> = conflict_detection_response + .conflicts + .into_iter() + .filter(|conflict| conflict.train_ids.contains(&virtual_train_id)) + .map(|mut conflict| { + conflict.train_ids.retain(|id| id != &virtual_train_id); + conflict + }) + .collect(); + + // Return the conflicts found along with the pathfinding result for the virtual train. + Ok(StdcmResponse::Conflicts { + pathfinding_result: pathfinding, + conflicts, + }) + } +} diff --git a/editoast/src/views/timetable/stdcm/request.rs b/editoast/src/views/timetable/stdcm/request.rs new file mode 100644 index 00000000000..cb46a42f57c --- /dev/null +++ b/editoast/src/views/timetable/stdcm/request.rs @@ -0,0 +1,289 @@ +use chrono::DateTime; +use chrono::Duration; +use chrono::Utc; +use editoast_models::DbConnection; +use editoast_schemas::rolling_stock::LoadingGaugeType; +use editoast_schemas::train_schedule::Comfort; +use editoast_schemas::train_schedule::MarginValue; +use editoast_schemas::train_schedule::PathItem; +use editoast_schemas::train_schedule::PathItemLocation; +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::core::pathfinding::PathfindingInputError; +use crate::core::stdcm::STDCMPathItem; +use crate::core::stdcm::STDCMStepTimingData; +use crate::error::Result; +use crate::models::temporary_speed_limits::TemporarySpeedLimit; +use crate::models::towed_rolling_stock::TowedRollingStockModel; +use crate::models::work_schedules::WorkSchedule; +use crate::models::List; +use crate::views::path::path_item_cache::PathItemCache; +use crate::views::path::pathfinding::PathfindingFailure; +use crate::views::path::pathfinding::PathfindingResult; +use crate::Retrieve; +use crate::SelectionSettings; + +use super::StdcmError; + +editoast_common::schemas! { + Request, + PathfindingItem, + StepTimingData, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)] +pub(super) struct PathfindingItem { + /// The stop duration in milliseconds, None if the train does not stop. + duration: Option, + /// The associated location + location: PathItemLocation, + /// Time at which the train should arrive at the location, if specified + timing_data: Option, +} + +/// Convert the list of pathfinding items into a list of path item +pub(super) fn convert_steps(steps: &[PathfindingItem]) -> Vec { + steps + .iter() + .map(|step| PathItem { + id: Default::default(), + deleted: false, + location: step.location.clone(), + }) + .collect() +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)] +struct StepTimingData { + /// Time at which the train should arrive at the location + arrival_time: DateTime, + /// The train may arrive up to this duration before the expected arrival time + arrival_time_tolerance_before: u64, + /// The train may arrive up to this duration after the expected arrival time + arrival_time_tolerance_after: u64, +} + +/// An STDCM request +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +pub(super) struct Request { + /// Deprecated, first step arrival time should be used instead + pub(super) start_time: Option>, + pub(super) steps: Vec, + pub(super) rolling_stock_id: i64, + pub(super) towed_rolling_stock_id: Option, + pub(super) electrical_profile_set_id: Option, + pub(super) work_schedule_group_id: Option, + pub(super) temporary_speed_limit_group_id: Option, + pub(super) comfort: Comfort, + /// By how long we can shift the departure time in milliseconds + /// Deprecated, first step data should be used instead + pub(super) maximum_departure_delay: Option, + /// Specifies how long the total run time can be in milliseconds + /// Deprecated, first step data should be used instead + pub(super) maximum_run_time: Option, + /// Train categories for speed limits + // TODO: rename the field and its description + pub(super) speed_limit_tags: Option, + /// Margin before the train passage in seconds + /// + /// Enforces that the path used by the train should be free and + /// available at least that many milliseconds before its passage. + #[serde(default)] + pub(super) time_gap_before: u64, + /// Margin after the train passage in milliseconds + /// + /// Enforces that the path used by the train should be free and + /// available at least that many milliseconds after its passage. + #[serde(default)] + pub(super) time_gap_after: u64, + /// Can be a percentage `X%`, a time in minutes per 100 kilometer `Xmin/100km` + #[serde(default)] + #[schema(value_type = Option, example = json!(["5%", "2min/100km"]))] + pub(super) margin: Option, + /// Total mass of the consist in kg + pub(super) total_mass: Option, + /// Total length of the consist in meters + pub(super) total_length: Option, + /// Maximum speed of the consist in km/h + pub(super) max_speed: Option, + pub(super) loading_gauge_type: Option, +} + +impl Request { + /// Returns the earliest time that has been set on any step + pub(super) fn get_earliest_step_time(&self) -> DateTime { + // Get the earliest time that has been specified for any step + self.start_time + .or_else(|| { + self.steps + .iter() + .flat_map(|step| step.timing_data.iter()) + .map(|data| { + data.arrival_time + - Duration::milliseconds(data.arrival_time_tolerance_before as i64) + }) + .next() + }) + .expect("No time specified for stdcm request") + } + + /// Returns the earliest tolerance window that has been set on any step + fn get_earliest_step_tolerance_window(&self) -> u64 { + // Get the earliest time window that has been specified for any step, if maximum_run_time is not none + self.steps + .iter() + .flat_map(|step| step.timing_data.iter()) + .map(|data| data.arrival_time_tolerance_before + data.arrival_time_tolerance_after) + .next() + .unwrap_or(0) + } + + /// Returns the request's total stop time + fn get_total_stop_time(&self) -> u64 { + self.steps + .iter() + .map(|step: &PathfindingItem| step.duration.unwrap_or_default()) + .sum() + } + + // Returns the maximum departure delay for the train. + pub(super) fn get_maximum_departure_delay(&self, simulation_run_time: u64) -> u64 { + self.maximum_departure_delay + .unwrap_or(simulation_run_time + self.get_earliest_step_tolerance_window()) + } + + // Maximum duration between train departure and arrival, including all stops + pub(super) fn get_maximum_run_time(&self, simulation_run_time: u64) -> u64 { + self.maximum_run_time + .unwrap_or(2 * simulation_run_time + self.get_total_stop_time()) + } + + /// Returns the earliest time at which the train may start + pub(super) fn get_earliest_departure_time(&self, simulation_run_time: u64) -> DateTime { + // Prioritize: start time, or first step time, or (first specified time - max run time) + self.start_time.unwrap_or( + self.steps + .first() + .and_then(|step| step.timing_data.clone()) + .and_then(|data| { + Option::from( + data.arrival_time + - Duration::milliseconds(data.arrival_time_tolerance_before as i64), + ) + }) + .unwrap_or( + self.get_earliest_step_time() + - Duration::milliseconds( + self.get_maximum_run_time(simulation_run_time) as i64 + ), + ), + ) + } + + pub(super) fn get_latest_simulation_end(&self, simulation_run_time: u64) -> DateTime { + self.get_earliest_departure_time(simulation_run_time) + + Duration::milliseconds( + (self.get_maximum_run_time(simulation_run_time) + + self.get_earliest_step_tolerance_window()) as i64, + ) + } + + /// Return the list of speed limits that are active at any point in a given time range + pub(super) async fn get_temporary_speed_limits( + &self, + conn: &mut DbConnection, + simulation_run_time: u64, + ) -> Result> { + let start_date_time = self.get_earliest_departure_time(simulation_run_time); + let end_date_time = self.get_latest_simulation_end(simulation_run_time); + if end_date_time <= start_date_time || self.temporary_speed_limit_group_id.is_none() { + return Ok(Vec::new()); + } + let temporary_speed_limit_group_id = self.temporary_speed_limit_group_id.unwrap(); + let selection_settings: SelectionSettings = SelectionSettings::new() + .filter(move || { + TemporarySpeedLimit::TEMPORARY_SPEED_LIMIT_GROUP_ID + .eq(temporary_speed_limit_group_id) + }); + let applicable_speed_limits = TemporarySpeedLimit::list(conn, selection_settings) + .await? + .into_iter() + .filter(|speed_limit| { + !(end_date_time <= speed_limit.start_date_time.and_utc() + || speed_limit.end_date_time.and_utc() <= start_date_time) + }) + .map_into() + .collect(); + Ok(applicable_speed_limits) + } + + pub(super) async fn get_stdcm_path_items( + &self, + conn: &mut DbConnection, + infra_id: i64, + ) -> Result> { + let locations: Vec<_> = self.steps.iter().map(|item| &item.location).collect(); + + let path_item_cache = PathItemCache::load(conn, infra_id, &locations).await?; + let track_offsets = path_item_cache + .extract_location_from_path_items(&locations) + .map_err(|path_res| match path_res { + PathfindingResult::Failure(PathfindingFailure::PathfindingInputError( + PathfindingInputError::InvalidPathItems { items }, + )) => StdcmError::InvalidPathItems { items }, + _ => panic!("Unexpected pathfinding result"), + })?; + + Ok(track_offsets + .iter() + .zip(&self.steps) + .map(|(track_offset, path_item)| STDCMPathItem { + stop_duration: path_item.duration, + locations: track_offset.to_vec(), + step_timing_data: path_item.timing_data.as_ref().map(|timing_data| { + STDCMStepTimingData { + arrival_time: timing_data.arrival_time, + arrival_time_tolerance_before: timing_data.arrival_time_tolerance_before, + arrival_time_tolerance_after: timing_data.arrival_time_tolerance_after, + } + }), + }) + .collect()) + } + + pub(super) async fn get_work_schedules( + &self, + conn: &mut DbConnection, + ) -> Result> { + if self.work_schedule_group_id.is_none() { + return Ok(vec![]); + } + + let work_schedule_group_id = self.work_schedule_group_id.unwrap(); + let selection_setting = SelectionSettings::new() + .filter(move || WorkSchedule::WORK_SCHEDULE_GROUP_ID.eq(work_schedule_group_id)); + WorkSchedule::list(conn, selection_setting).await + } + + pub(super) async fn get_towed_rolling_stock( + &self, + conn: &mut DbConnection, + ) -> Result> { + if self.towed_rolling_stock_id.is_none() { + return Ok(None); + } + + let towed_rolling_stock_id = self.towed_rolling_stock_id.unwrap(); + let towed_rolling_stock = + TowedRollingStockModel::retrieve_or_fail(conn, towed_rolling_stock_id, || { + StdcmError::TowedRollingStockNotFound { + towed_rolling_stock_id, + } + }) + .await?; + Ok(Some(towed_rolling_stock)) + } +} diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index dbb69b1a66d..eb583ce252b 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -1514,9 +1514,6 @@ export type PostTimetableByIdStdcmApiResponse = /** status 201 The simulation re simulation: SimulationResponse; status: 'success'; } - | { - status: 'path_not_found'; - } | { conflicts: Conflict[]; pathfinding_result: PathfindingResult;